Commit Graph

126 Commits

Author SHA1 Message Date
Nathan Colosimo d62b44473b [core] Prune schema modules from workflow bundles (#3550)
* [core] Prune schema modules from workflow bundles

* [world] Inline one-off validation options

* refactor(world): simplify event schema boundaries

* refactor(world): simplify event schema boundaries

* fix(world): keep noop metadata schema-free

* refactor(world): drop zod 4.4 compatibility

* test(builders): cover workflow API bundle boundary
2026-08-25 11:11:54 -07:00
Nathan Rajlich e1e64e3de3 docs: apply Vercel technical writing standards (#3704)
* docs: apply Vercel technical writing standards

Audit the complete documentation corpus, package READMEs, skills, and
source TSDoc/comments against the vercel-technical-writing skill and
style-rules.md. Normalize sentence-case headings without changing
published anchors, remove prose em dashes and filler wording, improve
active voice and self-contained phrasing, standardize product/brand
capitalization, American English, list punctuation, units, and code
fence languages, and preserve exact runtime strings/table placeholders.

All executable code is unchanged. Modified skills have their metadata
versions bumped.

* docs: extend writing audit to repository Markdown

Apply the same technical-writing rules to design documents, compiler
specifications, workbench guides, package changelogs, and the remaining
tracked Markdown outside the deployed docs corpus. Preserve historical
meaning, commands, output literals, table placeholders, and heading
anchors.

* docs: exclude generated package changelogs from audit
2026-08-21 14:24:31 -07:00
josuediazflores 8a2648e35f fix(builders): skip generated writes that would not change the file (#3454)
Signed-off-by: josuediazflores <152042011+josuediazflores@users.noreply.github.com>
Signed-off-by: Peter Wielander <peter.wielander@vercel.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
2026-08-19 16:57:24 -07:00
Luca Maraschi 4ec7acaa71 feat(builders): observe accepted transforms (#3163)
Signed-off-by: Luca Maraschi <luca.maraschi@gmail.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-08-10 13:08:53 -07:00
Shalabh Chaturvedi 264ddff67b Add WebSocket transport for step-execution event writes (opt-in) (#3084)
* sdk side for workflow server websockets

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* hardcoded workflow server

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* debug info

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* more debug

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* remove unnecessary debug

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* default on websockets, and override url

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* fix for missing funcs

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* make websockets opt outo

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* enable ws again

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* [revert later] reduce test to single test, test both http and ws at the same time

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* empty

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Run full suite with and without ws

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* empty

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* improve e2e test

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* minimize tests

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* fallback to http when proxy present

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* default to websockets, remove matrix

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* remove smoke test

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* update to new protocol

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* adjust for new protocol (runid in path)

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* fix ws transport error

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* blank

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* fix ws dep

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* fix ws external: only accelerators, not ws itself

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* add dedicated WS-transport e2e job; flip WS default back to opt-in

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* build all packages before local vercel build (needs workflow/nitro on disk)

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* force NITRO_PRESET=vercel for the local vercel build step

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* install vercel CLI once instead of npx-ing it per command

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* add changeset for WS events transport

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Harden the WS events transport and gate its e2e jobs

Follow-ups from review of the WS transport.

CI: `e2e-vercel-ws-transport` was wired into the `summary` job but not
into `e2e-required-check`, so all three WS jobs could fail while the
required check stayed green. Added to both branches of the status
validation — including the `workflow-server-test` label branch, where
the job runs under the same gating as `e2e-vercel-prod`.

Transport:

- `reqId` and the pending-reply map are now per connection rather than
  per transport. The protocol defines `reqId` as a per-connection
  counter, so a reconnected socket restarts at 1; with one shared map
  that collided with the previous socket's still-registered waiters.
  It also makes the superseded-socket guard structural instead of
  something the close path has to remember.
- Post-open socket errors are no longer silent. The only `'error'`
  listener closed over the connect promise's `reject`, already settled
  once `'open'` fired, so every broken pipe / 1009 / protocol fault was
  swallowed and its requests hung with no per-request timeout to save
  them. Now logged, and the connection is torn down.
- An unexpected close reconnects eagerly instead of waiting for the next
  write, since a socket breaking mid-run means more writes are coming.
  Bounded by exponential backoff, an attempt cap that falls back to
  lazy reconnect, a bail-out when a newer socket is already live, and an
  `unref()`ed timer so a backoff window can't delay handler exit.
- `ws.send()` failures reject their request. `send()` doesn't throw on a
  non-OPEN socket — it reports through a callback we weren't passing —
  so the request just sat in `pending` forever.
- The reserved `reqId: -1` malformed-frame reply and undecodable frames
  are logged loudly instead of dropped.
- Auth headers resolve once per socket via a thunk, not once per event.
  The bearer only rides the upgrade, so the old code awaited
  `getVercelOidcToken()` on every write and discarded all but the first.
  Re-resolving on reconnect also means a new socket gets a fresh token.

Adapter: a reply with no numeric status now fails closed. Defaulting to
200 reported a write as applied whenever the client met a frame it
didn't understand — and the protocol is explicitly designed to grow new
response variants.

Tests: 24 new unit tests over the paths the e2e suite can't reach on
demand (send failure mid-flight, error after open, late close from a
superseded socket, reconnect backoff and give-up, sentinel/undecodable
frame logging, one-token-per-socket) plus the adapter's fail-closed and
typed-error mapping.

Co-Authored-By: Shalabh Chaturvedi <shalabhc@users.noreply.github.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* ci: re-trigger to confirm the prior e2e failures were flake

No code change. The 5 failures on 0bb21e7 clustered in a ~20s window
across HTTP-path jobs (example/nuxt on the same test, sveltekit on a
timeout) and one WS job (sleepingWorkflow's clock-skew assertion), which
points at the environment rather than the transport changes. Re-running
to confirm.

Co-Authored-By: Shalabh Chaturvedi <shalabhc@users.noreply.github.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Add WS wire-contract conformance tests and pin the transport gate

Three gaps in the existing coverage.

**The HTTP path was already covered** — `events-v4.test.ts` has 22 tests,
including five directly on `createWorkflowRunEventV4` over HTTP (alias
URL, frame meta contents, response decoding, skipPreload/stateUpdatedAt
forwarding). Those run with the gate unset, so they do confirm the
two-branch refactor didn't disturb HTTP. No new tests needed there.

**But nothing pinned the gate itself.** Every HTTP assertion stays green
if the default flips to WS, because the transports are built to be
indistinguishable at the result layer — and an earlier revision of this
branch did flip the default deliberately, for benchmarking. Added tests
for `isWsEventsTransportEnabled()` across values, and one that drives a
real HTTP request through a MockAgent while asserting the WS transport is
never constructed.

**Nothing verified the bytes.** `ws-transport.test.ts` replies with
whatever the test hands it, which proves the client's lifecycle but not
that its frames are what workflow-server accepts. That's the drift the
spec doc exists to prevent, and it already happened once: event meta flat
on the frame where the server wanted it nested under `event`, with both
sides' tests passing.

`ws-protocol-conformance.test.ts` pairs the real client stack (through
`createWorkflowRunEventV4`) with a fixture mirroring the server route's
per-message handling: decode one frame, validate against a local copy of
`WsRequestFrameSchema`, dispatch, encode the reply the way `replyMeta`
does. `experimental_upgradeWebSocket` needs a real Vercel runtime, so the
socket is faked — everything above it is genuine.

Covers: the frame shape the server accepts (and that `reqId`/`type`/
`runId` don't leak into the event meta), payload passthrough, exactly one
frame per message, 409 → the same typed error HTTP raises, fail-closed on
an unknown reply variant, and reqId correlation across concurrent writes.

Plus golden byte fixtures, since the schema copy is the one thing here
that can silently drift. This is the "golden-frame interop test" the
server spec lists as an open gap; the matching half still needs to land
in workflow-server.

Verified the conformance suite is not vacuous: flattening the client's
frame meta fails 5 of its tests.

Co-Authored-By: Shalabh Chaturvedi <shalabhc@users.noreply.github.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* match the HTTP RetryAgent's transient-failure policy on WS

HTTP event writes go through an undici RetryAgent (RETRY_AGENT_OPTIONS):
5xx and transient connection errors are retried in-process, honoring
Retry-After. The WS path never touches undici, so it shipped with no
transient-failure handling at all — a single 503 or a mid-write reset
surfaced straight to the step runtime and cost a whole step retry where
HTTP would have absorbed it in milliseconds.

That gap is invisible in a passing test run: writes still succeed, they
just cost far more. So copy the policy rather than reinvent it —
[500, 502, 503, 504] plus transport failures, undici's default backoff,
Retry-After honored, and 429 deliberately excluded for the same reason
RETRY_AGENT_OPTIONS excludes it (a firewall challenge this client cannot
solve, which in-process retries only amplify).

Adds WsTransportError so retryability is a typed property of the failure
rather than something the adapter infers by string-matching. Splits
resolveWsTransport()/wsReplyStatus() out of postEventFrameOverWs so the
retry loop stays readable.

The existing "fails closed on an error frame" test used status 500,
which is now absorbed by the retry — switched to 403 so it keeps
testing fail-closed rather than accidentally testing no-retry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Shalabh Chaturvedi <7066873+shalabhc@users.noreply.github.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* lazy-load `ws` so the default HTTP path never evaluates it

events-v4.ts imports ws-transport.js unconditionally — the transport
gate is a runtime branch, not a build-time one — so a top-level
`import { WebSocket } from 'ws'` put `ws` and its optional native
accelerators on the module-init path of every deployment, including the
overwhelming majority that never opt in and never open a socket.

Defer it to the first connect, memoized as a promise so concurrent
first connects share one import. WebSocket.OPEN becomes an inlined
constant so the readyState check doesn't pull the module in just to
read it off the constructor.

This does NOT remove the need for the bufferutil/utf-8-validate
externals this branch also adds: webpack and Rollup both statically
follow a dynamic import(), so the build-time story is unchanged. What
it buys is that a deployment which never enables the transport never
*evaluates* `ws`, so a mis-bundled accelerator can't break it.

The test lives in its own file because vitest caches a vi.mock factory
result for the life of the module registry — once any test in a file
has connected, the factory never runs again and the counter can't
distinguish "loaded lazily" from "loaded at import".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Shalabh Chaturvedi <7066873+shalabhc@users.noreply.github.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* release idle WS transports instead of renewing them forever

The transports map was never pruned and WsEventsTransport had no way to
close. Combined with eager reconnect that made a connection immortal by
construction: the server drains at its own maxDuration and closes, the
client immediately reopens, and the server pins a fresh invocation — for
a run that finished long ago. A warm container ended up holding a live
socket, and a live server invocation, for every runId it had ever
served. workflow-server#683 already lists "one invocation stays resident
per run rather than per write" as a known gap; this made it "per run,
forever".

Add close() plus a 60s idle release. There is no "run complete" signal
to hang teardown off — the events adapter is a stateless per-write call
— so idleness is the available proxy. 60s sits well below the server's
~680s drain deadline, so the client releases rather than the server
reclaiming, and well above the gap between steps of an active run.

scheduleReconnect() now bails when closed: close() closes the socket,
which fires the same close handler an unexpected drop would, and without
the guard the transport would instantly reconnect what it just released.

request() revives an idle-closed transport rather than failing the
write, re-registering itself only if nothing newer has claimed the map
slot. Eviction therefore costs one handshake, not an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Shalabh Chaturvedi <7066873+shalabhc@users.noreply.github.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* refresh the bearer on an auth_expiry drain

workflow-server#683 tags a drain frame with why it is closing:
max_duration means the socket aged out and a plain reconnect is right,
auth_expiry means the *bearer* ran out and reconnecting with the same
one just earns a 401. This client logged the drain and ignored the
reason, so against #683 an auth_expiry drain would burn all five
reconnect attempts against a token the server had already rejected, then
give up.

Parse the reason (absent reads as max_duration, so this stays correct
against the currently-deployed server) and thread forceRefresh through
the getHeaders thunk, which triggers @vercel/oidc's refresh path via a
wide expirationBufferMs.

Worth being precise about when that can actually help. getVercelOidcToken
resolves getContext().headers['x-vercel-oidc-token'] ?? env, and
refreshToken() only writes the env var — the request-context header
wins. So inside a deployed function there is genuinely no fresher token
mid-invocation and the refresh is a no-op; outside one (CLI, local dev,
a long-lived server) it works.

That makes the guard the load-bearing half: if the re-resolved bearer is
byte-identical, decline to reconnect, say so, and wait for the next
write — which usually arrives on a new invocation carrying a new token.
That failure is marked non-retryable so the retry loop doesn't spin on
it either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Shalabh Chaturvedi <7066873+shalabhc@users.noreply.github.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* document the WS path's instrumentation gap

The HTTP branch goes through fetchV4 -> instrumentedFetch, which is not
just a fetch wrapper: it opens the OTEL CLIENT span, injects trace
context, sets the cache-bust header, emits the DEBUG logs, and routes
through the global fetch that Vercel's observability "outgoing requests"
view instruments. The comment on fetchV4 records why that matters —
bypassing it via undici.request() is exactly what once made v4 event
traffic disappear from the log viewer.

The WS branch bypasses all of it. With the flag on, per-event writes
have no client span, propagate no trace context to workflow-server, and
don't appear in the outgoing-requests view; the server's own
transport-tagged request metrics are the only remaining signal.

That's acceptable for an opt-in POC behind a flag and unacceptable as a
default, so write it down where someone deciding to flip the default
will read it: instrumenting the transport is a prerequisite for that,
not a follow-up nicety.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Shalabh Chaturvedi <7066873+shalabhc@users.noreply.github.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* ship the ws-accelerator externals instead of documenting a workaround

`bufferutil` and `utf-8-validate` are optional native accelerators for
`ws`, and neither is installed by default. Every bundler has to be told
to leave them alone, for two different reasons: Rollup/Vite/Nitro fail
the build outright (`Could not resolve "bufferutil" imported by "ws"`),
while webpack bundles the JS wrapper without its native `.node` binding
and throws `bufferUtil.mask is not a function` at runtime.

The webpack half shipped in `@workflow/next`. The Rollup half only
existed in `workbench/vite` and `workbench/tanstack-start` as
`nitro.rollupConfig.external` — app configs, not shipped code. So a real
user of `@workflow/vite`, `@workflow/nitro`, `@workflow/nuxt`,
`@workflow/sveltekit` or `@workflow/astro` hit the same build failure the
workbench had already worked around, and had to rediscover the fix.

Fix it where it propagates: `workflowTransformPlugin` in
`@workflow/rollup`, which all of those integrations already install. It
is already the home of exactly this pattern for the optional
`@opentelemetry/api` peer, so this sits next to its closest precedent.

Note the treatment is deliberately the inverse of the OTEL one, which is
externalized only when it *can't* be resolved. The OTEL API must load
for tracing to work, so a self-contained output has to bundle it when
present. These accelerators must specifically NOT load — they are a
performance nicety with a correct try/catch fallback in `ws` — so
unconditional external is both simpler and safer than risking a
half-bundled native module.

The two workbench configs drop their local copies, which is what proves
the shipped fix actually works rather than being masked by them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Shalabh Chaturvedi <7066873+shalabhc@users.noreply.github.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* one retry policy for both transports, and no unanswerable waiters

Two review findings on the WS events transport.

**Retry belongs to `event-retry.ts`, not the adapter.** The WS path had its
own retry loop, justified as mirroring undici's `RetryAgent`. That
justification was wrong: `RetryHandler` defaults `methods` to GET/HEAD/
OPTIONS/PUT/DELETE/TRACE and nothing overrides it, so the `RetryAgent` never
retried an event POST on either transport — which is precisely why
`event-retry.ts` exists.

Worse, that loop sat *inside* `withEventPostRetry`, so it defeated a
compile-checked safety gate: `EVENT_RETRY_ELIGIBILITY` marks `step_started`,
`step_retrying` and `hook_received` non-retryable (a replayed `step_started`
double-increments `attempt`), and those frames were re-sent up to five times
before the gate ever saw a failure. For eligible types the two loops
multiplied: 3 outer attempts x 6 inner, with an inner backoff reaching 30s
against an outer base deliberately set to 100ms.

`postEventFrameOverWs` now makes one attempt and translates failures into the
vocabulary that policy already speaks — a transport failure becomes a
`WorkflowWorldError` with `code: 'TRANSPORT'`, exactly as `utils.ts` does for
a failed `fetch`, and `isRetryableEventPostError` gains one clause keyed on
that code. `WsTransportError` loses its `retryable` flag; its only consumer
was the deleted loop.

Two deliberate consequences. The code-keyed clause broadens HTTP in-process
retry to `UND_ERR_CONNECT`, `UND_ERR_CLOSED` and `EAI_AGAIN`, which were in
utils.ts's transient set but missing from event-retry.ts's — two
hand-maintained lists collapsed into one semantic code. And the stale-token
case (drain for auth expiry, refresh yields the same bearer) now gets two
in-process attempts that cannot succeed, ~300ms before it falls through to
queue redelivery; that is cheaper than keeping a WS-specific policy alive for
one call site. `TIMEOUT` is deliberately not in the clause: utils.ts maps a
caller-supplied `AbortError` onto it, and a cancelled write must not be
re-issued.

A status-less reply also stops being a bare `Error` — as one it failed
`WorkflowWorldError.is()` and surfaced a protocol version skew as a
USER_ERROR. It is now `code: 'PARSE_ERROR'`, the same code utils.ts uses for
an unreadable HTTP body, and for the same reason: the write may or may not
have landed.

**No waiter is left unanswerable.** An undecodable frame, the server's
malformed-frame sentinel (`reqId: -1`) and a non-numeric `reqId` were logged
and dropped. None can be correlated by construction, so the request that
provoked them stayed in `pending` with nothing in existence able to settle it
— freed only by the server's own drain (~680s from connect), typically past
the invocation's `maxDuration`. Each now fails the connection: every waiter
learns why, and the socket is replaced. A reply for an id nobody is waiting
on stays log-and-drop, deliberately — that request already settled, so
nothing is orphaned, and failing the socket would punish healthy in-flight
writes.

A per-request deadline backs that up for whatever is left, including a server
that accepts a frame and never answers it. Same knob as the HTTP path
(`WORKFLOW_REQUEST_TIMEOUT_MS`, 60s), whose doc comment already describes
this exact hang-to-SIGTERM pathology.

One existing idle-teardown test needed the deadline raised: the idle window
and the default deadline are both 60s, so a request could not outlive the
former without also outliving the latter. The test is about `inFlight > 0`
suppressing the teardown, so it now sets the deadline out of the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Open the ws socket when the invocation starts, not on its first write

Lazily connecting bills the whole handshake — an upgrade round-trip plus the
OIDC token mint that rides it — to whichever event a fresh invocation writes
first. When that is a `step_started` issued as the step body is already
running, the event's server-recorded timestamp lands later than the work it
describes: the step looks shorter than it was. That is the shape of the e2e
timing failure on this branch, where a 9s step measured 6.5s from
`getStepMetadata().stepStartedAt`.

The queue handler is the earliest point that knows the run id, and a message
delivered for a run means writes are coming, so `warmWsEventsTransport` starts
the handshake there. By the first write it is done or in flight, and the write
just uses it.

Nothing about it is load-bearing:

- It doesn't await, and can't fail the handler. A warm that fails logs and
  stops — a never-opened first connect is precisely the case `connect`'s close
  handler already declines to retry, so no backoff loop starts for a run that
  may never write. The first real write connects as it would have anyway,
  carrying the shared retry policy.

- No-op unless `WORKFLOW_EVENTS_TRANSPORT=ws`, and no-op for the api-workflow
  proxy World, which can't serve an upgrade at all — the same fallback the
  write path takes.

- Warming arms the idle timer as if a request had settled, so an invocation
  that warms and never writes (a health probe carrying the run id it is about
  to create) releases its socket on the usual 60s rather than stranding it.
  The socket is not `unref`'d, so a stranded one would hold this process and a
  server invocation open.

Also closes a race that warming makes reachable: `close()` can only drop the
connection it can see, so a release landing mid-handshake left the socket to
install itself afterwards onto a transport already evicted from the cache,
which nothing would then ever close. The `open` handler now declines to adopt
a socket whose transport was released while it was connecting. This was
already reachable via the eager reconnect path, just much harder to hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* changeset: just the env var

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* inline the ws-accelerator predicate at its only call site

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* refactor(world-vercel): trim ws-transport comments

Comments were 47% of the file. Cut the historical narration, the
restatements of adjacent code, and the repeated rationale (the `unref`
reasoning appeared four times, per-connection reqId three), keeping the
non-obvious facts: `ws.send()` reports failure via callback instead of
throwing, reqId is per-connection so `pending` must be too, the
unknown-reqId case is deliberately non-fatal, the auth_expiry same-token
bail-out, and why the idle timeout exists at all.

No code changes.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* own transport selection in the transport module

`events-v4.ts` was assembling the WS transport itself: reading the opt-in
flag, resolving the URL, deciding which Worlds can use a socket, minting the
per-connection header thunk, and holding the two once-per-process log latches.
None of that is about turning an event into a frame, which is what the rest of
that file does. Move it next to the socket it configures — `events-v4.ts` now
consumes one seam (`resolveWsTransport`) plus the gate, and `queue.ts` gets
`warmWsEventsTransport` from the module that owns the warm.

`headersToRecord` now lives in `http-core.ts` because both callers need it and
neither may import the other: `events-v4` already depends on the transport, so
the reverse edge would be a cycle.

Test fallout, and the reason the move is worth it: `events-v4-ws.test.ts`
mocked `getWsEventsTransport` to observe the resolve step, which no longer
intercepts anything now that the call is intra-module — an ESM mock replaces a
module's exports, not its own call sites. That mock's tests were only ever
about selection, so they move to `ws-transport.test.ts`, where the real
selection code runs against the existing fake-socket harness instead of a
stub. `resetWsEventsTransportsForTest` clears the log latches so the
once-per-process assertions don't depend on test order. What stays behind
mocks `resolveWsTransport` and covers what that file is actually for: reply
frame in, `Response`-shaped result out — including the null-resolve fallback to
HTTP, which nothing covered before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* import `ws` statically

The lazy `import('ws')` was there to keep the package off the module-init path
of deployments that never opt in — `events-v4.ts` imports this module
unconditionally, since the transport gate is a runtime branch. Measured, that
buys ~17ms: `require('ws')` is 16.5-18.0ms cold, 13 modules, and neither
`bufferutil` nor `utf-8-validate` loads (optional peers, absent by default).
Bundle size is identical either way — webpack and Rollup both statically follow
a dynamic `import()`, which is why the externals in `@workflow/builders` are
unaffected by this change.

For 17ms it cost a memoized promise, an inlined `WS_READY_STATE_OPEN` (so a
readyState check wouldn't force the module to load just to read a constant off
the constructor), and a whole test file — `ws-transport-lazy.test.ts` had to
live alone, because vitest caches a `vi.mock` factory result for the lifetime
of a module registry, so only a file that connects exactly once can observe
the laziness at all.

It also skewed the thing this branch exists to measure. The import lands inside
the first connect, so on a warm container it is billed to whichever event write
opens the socket, inflating the timestamp of the step it labels — the same
distortion the queue pre-warm was added to remove.

Also drops `WS_READY_STATE_OPEN` in favour of `WebSocket.OPEN`, now that
reading it is free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* tighten the comments on the ws transport

Comments only — no code changes in this commit.

Cuts ~150 lines of prose across the WS additions. The rule applied: keep the
design factors a future reader needs (why the connection is scoped to a run,
why a bad reply takes the socket down, why the accelerators are externalized
unconditionally, why `TIMEOUT` is excluded from the `TRANSPORT` classification)
and drop the narrative of how the code got here — which revision did what, what
an earlier attempt got wrong, what was measured on the way. That history lives
in the PR and the git log, where it doesn't have to be re-read on every visit
to the file.

Biggest reductions: the retry essay above `postEventFrameOverWs` (30 lines to
11), the flag's OTEL-gap note (34 to 13), the OIDC refresh explainer (26 to
14), the accelerator rationale in `@workflow/builders` (26 to 14), and the
conformance suite's header (28 to 17).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* inject W3C trace context on the ws upgrade

Frames carry no headers, so the upgrade is the only place this transport can
propagate context; the server parents a run's event spans to whichever
invocation opened the socket. Covered in trace-propagation.test.ts, both with
and without an active span.

Splits the opt-in gate into an import-free ws-transport-enabled.ts so callers
can answer it without loading this module (used by the next commit).

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* load the ws transport module only when it is enabled

Both call sites read the gate from the import-free module and dynamically
import ws-transport.js behind a true result, so a deployment on the HTTP
default never pays ws's ~17ms of module init. The queue pre-warm absorbs it
for one that opted in, keeping it off the first event write.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* document WORKFLOW_EVENTS_TRANSPORT as experimental

Names the instrumentation gap (no client span per write) and the proxy path
where the variable is ignored.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* correct why the ws accelerators are externalized

No bundler fails the build on the unresolvable require — verified against
Rollup 4.62. webpack half-bundles the native module and Vite substitutes a stub
that makes the require succeed; both leave bufferUtil.mask undefined and throw
only once a frame reaches the native masker at 48 bytes, which every CBOR event
frame does. Same claim was repeated in the rollup plugin and its test.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* trim the WORKFLOW_EVENTS_TRANSPORT docs to user level

Mirrors the other Vercel World env vars: same facts on both pages, each in its
page's format. The instrumentation and socket-lifetime detail belongs in the
code, not in a user-facing reference.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* cover the vite bundler in the ws transport lane

Vite substitutes a stub for ws's absent native accelerators rather than failing
the require, so nothing catches it until a masked frame reaches 48 bytes — and
this job's three existing lanes are esbuild, turbopack and nitro.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* claim only what is measured about rollup and the ws accelerators

The rationale asserted plain Rollup was "safe by accident" via a mechanism
only ever observed in a minimal repro. Nitro traces and externalizes `ws` in
a production build, so the bundled path is not reached there at all.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Give the events socket an explicit lifetime instead of an idle timer

`openWsChannel` / `closeWsChannel` bracket one invocation of the flow
route, and are the only calls anywhere that create a channel. Writes ask
`resolveWsTransport` whether one is open — a lookup now, never a create —
and take pooled HTTP when it says no.

That removes the reason the idle timeout existed. A lazily-created socket
has no owner, so a timer was the only thing able to end it, and the socket
is not `unref`'d: the process could not exit, and a server invocation
stayed pinned, for the full window past the last write.

It also settles `run_created`. The trigger path opens no channel, so a
lone write no longer pays for a handshake it cannot amortize — `start()`
runs in an arbitrary request handler with no boundary the SDK can see.

Refcounted rather than a flag: inline step executions ride the flow topic
on per-step topics, so a run's steps can be concurrent invocations in one
instance sharing the channel, and the first to finish must not cut the
others short. A failed connect closes the channel so the invocation's
writes fall back to HTTP instead of each paying its own doomed handshake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Name the one reply header the WS path does not map

The server copies six headers into an `event_ack`'s meta and this record
maps five. The sixth, `X-API-Deprecated`, is inert today — the v4 route's
middleware chain has no deprecation middleware to set it — but the record
is the only header source a WS reply has, so an unmapped key is gone
rather than merely unread, which is not true of the `Response` the HTTP
path returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* docs: note that WORKFLOW_EVENTS_TRANSPORT=ws is ignored on the proxy path

The api-workflow proxy is an HTTP-only REST gateway and does not forward
a WebSocket upgrade, so a World configured with projectConfig keeps
writing events over HTTP regardless of the setting.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* ci: gate the ws-transport e2e lanes on a label

Three real `vercel deploy`s per run is too much to charge every
unrelated PR in the repo for a transport that is off by default. PRs opt
in with `ws-transport-test` (or `workflow-server-test`, which already
exists to test the half of this the protocol lives in); main keeps the
signal on every commit.

The required aggregate has to allow the lane to be skipped in that case,
so its status is asserted only when the lane was actually supposed to
run.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* chore: regenerate pnpm-lock against current main

main resolved `ws` to 8.20.0 as a transitive peer; this branch adds it
as a direct dependency of world-vercel and floats it forward, which
rewrites every `openai@x(ws@y)` peer key in the lockfile. Merging main
textually combined the two, leaving those keys pointing at a `ws` entry
the merged file no longer had — `--frozen-lockfile` then failed with
ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY on the PR's merge ref.

Regenerated from main's lockfile so ours is a minimal delta on top of it.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* fix(world-vercel): align ws on the version main already resolves

The lockfile broke on the PR's merge ref, not on this branch's head: main
resolves ws@8.20.0 as a transitive peer, and a `^8.21.1` direct dep here
floated it forward, rewriting all 73 `(ws@8.20.0)` peer keys. Git merged
the two lockfiles without a conflict but left main-side keys pointing at
a ws entry the merged file no longer had, so `--frozen-lockfile` failed
with ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY.

`^8.20.0` resolves to the copy main already has, so the lockfile delta is
the two importer entries instead of a repo-wide rewrite that re-breaks
every time main moves. Also keeps one ws in the store rather than two.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Bind the channel release to the instance it claimed

closeWsChannel resolved the transport by URL, but the refcount lives on
the instance. A channel is evicted from the map as soon as it closes — a
refused upgrade does that on the connect path — so the next opener for
the same run registers a different instance under the same URL, and the
first invocation's close then decremented that one instead. It dropped a
socket a live invocation was still writing over, and for the event types
EVENT_RETRY_ELIGIBILITY marks non-retryable there is no second attempt to
carry the in-flight write over HTTP.

openWsChannel now returns an idempotent release closed over the transport
it incremented, and queue.ts holds that instead of re-resolving the run.
The close awaits the open's own promise, so it also can no longer land
ahead of the claim it releases.

Also names the scope of the connect-failure de-opt: it covers the
handshake only, so a channel that connects and then fails every write
keeps taking the WS path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Decode a transport result, not a Response

Main extracted the v4 POST decode into a helper typed `Response` while this
branch narrowed the POST result to `FrameResponseLike`, because the WS branch
synthesizes its result rather than holding a real `Response`. The two merge
without a textual conflict and then fail to typecheck.

Widen the helper: it reads only the two members `FrameResponseLike` declares,
and a `Response` still satisfies them, so the HTTP call sites are unchanged.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Re-run CI

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Re-run CI

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Reconcile the WS transport with main's v4 POST rework

main moved the materialized POST result off the `x-wf-*` response headers
and onto a typed CBOR body, and added a second response shape: two callers
now POST with `Accept: application/vnd.workflow.v4-frames` and read back a
sentinel-terminated sequence of frames.

A frame stream has no representation in a protocol that pairs one reply
frame with one request frame, so the WS switch moves off the shared poster
and onto `createWorkflowRunEventV4` alone — the materialized write, which is
the hot per-step path this branch exists to shorten. `run_started` and the
`hook_received` preload stay on HTTP.

`decodeCreateEventResponse` takes `FrameResponseLike` rather than `Response`
because the WS branch has none to hand over; a real `Response` satisfies the
interface, so the HTTP callers are unchanged. The ids now come out of the
CBOR body, so `replyMetaToHeaderRecord` no longer maps any `x-wf-*` name —
only the two headers `errorFromV4Response` reads.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Re-run CI

Resample the WS-arm sleepingWorkflow failure: it has now recurred on a second
axis (nextjs-turbopack, 7709ms; previously vite, 7570ms), so the arm needs
more samples before the skew can be called WS-specific or repo-wide flake.

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Re-run CI

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* Re-run CI

Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>

* blank

* blank

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-08-09 17:34:44 -07:00
Nathan Rajlich 32ac8e73fd Fix Biome lint violations and add Biome CI check (#3222)
* Fix Biome lint violations and add Biome CI check

Biome was not configured to respect .gitignore, so ~92% of the 13,355
reported diagnostics came from gitignored build artifacts. Enable VCS
integration (useIgnoreFile), apply safe auto-fixes across the repo, fix
the remaining mechanical errors by hand, downgrade judgment-call a11y /
dangerouslySetInnerHTML rules to warnings, and add a 'biome ci' job to
the Lint workflow so violations block PRs going forward.

* Use an empty changeset (no behavior change, no release needed)
2026-07-30 22:32:12 +00:00
Peter Wielander a09d00135b Revert "Statically inject workflow world target" (#2752) (#3142) 2026-07-29 08:55:29 -07:00
Peter Wielander 49276f2d0b [utils] Fix vercel world not being selected when running build on external CI (#3144) 2026-07-28 11:26:30 -07:00
Nathan Colosimo 62d570ed4b Remove retired v1 step route plumbing (#3061) 2026-07-24 23:50:55 +00:00
Copilot b406a04dfa Optimize processImportSpecifier by computing shouldFollowImportsFromFile once per file (#3052) 2026-07-24 18:29:28 +00:00
Peter Wielander cfe7570d67 [builders] Add opt-out for discovering workflows in node_modules (#3054) 2026-07-23 22:57:26 +00:00
Nathan Colosimo 927b61ab41 Fix dotted tsconfig alias workflow discovery (#2963)
* Fix dotted alias workflow discovery

* Increase streamer stress test cleanup timeout

* Increase canary HMR rediscovery timeout
2026-07-16 22:14:10 -07:00
Peter Wielander a00d169470 Add stateUpdatedAt precondition guard to event creation (#2266) 2026-07-15 02:11:14 +00:00
Peter Wielander a4d8de03e6 [world-vercel] [builders] Add WORKFLOW_SEQUENTIAL_REPLAYS option to limit flow route concurrency to one (#2193) 2026-07-13 20:57:38 +00:00
Peter Wielander 25b1509e19 [rollup] Externalize optional @opentelemetry/api peer (only when absent) so framework builds don't fail (#1947) 2026-07-10 10:13:34 -07:00
Nathan Colosimo df7e71de1c fix(builders): harden Windows HMR file writes (#2853) 2026-07-09 15:57:41 -07:00
Nathan Colosimo 49a50e83d9 Document configuration environment variables (v5) (#2468) 2026-07-07 17:56:41 -07:00
Nathan Colosimo 2ca34ac69c fix(sveltekit): production server crash from bundled TypeScript compiler (#2799)
* fix(sveltekit): patch server chunks with rollup-renamed __filename bindings

The adapter-node chunk patch skipped any chunk matching
/\b(const|let|var)\s+__(file|dir)name\b/ — but $ is not a regex word
character, so rollup-renamed declarations like `__filename$1` (produced
when adapter-node re-bundles the intermediate server output and our
banner's declaration collides) satisfied the check. Chunks that declared
only a renamed binding while a bundled CJS dependency referenced the
bare `__filename` were skipped, and the production server crashed at
boot (observed on main with the TypeScript compiler bundled via
cosmiconfig through @workflow/world-postgres).

Anchor both regexes with (?![\w$]) so renamed identifiers no longer
match. Verified: the sveltekit workbench production server now boots and
serves health checks (with and without a base path), and queue
deliveries from start() succeed.

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>

* fix(sveltekit): keep the TypeScript compiler out of the server bundle

Since the world-target injection change, the sveltekit workbench's
hooks.server.ts imports @workflow/world-postgres, whose dependency chain
(graphile-worker -> cosmiconfig) reaches cosmiconfig's TS-config loader.
At runtime that loader's require('typescript') is lazy and never fires,
but SvelteKit bundles the whole chain into the server and rollup's CJS
conversion hoists it into an eager top-level evaluation — executing the
entire TypeScript compiler at boot and crashing the server
("__filename is not defined" inside the bundled compiler).

Alias 'typescript' to a stub module in the SvelteKit plugin, following
the existing pg-native pattern. Server output shrinks from 36MB to 11MB
and boots cleanly.

Verified: sveltekit workbench production build boots, serves
flow?__health (200), and start() runs execute with clean queue
deliveries. The chunk-patch regex fix from the previous commit stays as
hardening for any other CJS dependency that references __filename.

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>

* Update .changeset/fix-sveltekit-filename-chunk-patch.md

Signed-off-by: Peter Wielander <mittgfu@gmail.com>

---------

Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-07-07 23:07:13 +00:00
Nathan Colosimo 239031ad9e fix(next): respect basePath for workflow routes (#2732)
* fix(next): respect basePath for workflow routes

* docs(core): note workflow URL resolution gap

* fix(next): expose workflow health route methods

* test(utils): remove workflow route helper tests

* test(builders): remove route handler string test

* fix(next): defer basePath validation to Next.js

* refactor(utils): remove workflow url helper wrappers

* Test Next basePath builder wiring
2026-07-06 16:43:35 -07:00
JJ Kasper 0f557d5ae4 Statically inject workflow world target (#2752)
* Statically inject workflow world target

* Fix static world injection in host bundles

* Fix static world injection gaps

* Fix Vite Nitro server startup

* Fix Nitro pg-native aliasing

* Fix static world target CI gaps

* Fix static world dev rebuild gaps

* Avoid broad runtime alias in Nitro

* Refresh Next dev route for step HMR

* Externalize Nest target world

* Use canary HMR rediscovery timeout

* Bundle local world in Nest builds

* Dedupe world target helpers and fix SvelteKit chunk patch guard
2026-07-06 14:19:45 -07:00
Nathan Colosimo 532b0e10d6 fix(vite-frameworks): derive workflow roots (#2725)
* fix(vite-frameworks): derive workflow roots

* chore(vite-frameworks): simplify derived roots

* chore(vite-frameworks): simplify root derivation

* fix(sveltekit): respect configured routes directory

* test(frameworks): cover builder option pass-through

* test(frameworks): remove builder option tests

* fix(sveltekit): assert loaded routes config

* docs(sveltekit): note internal config loader
2026-07-01 22:50:02 -07:00
Nathan Colosimo 7615d999f1 Fix graph extraction for annotated step proxies (#2702) 2026-06-30 09:16:43 -07:00
JJ Kasper f6772d95c8 Optimize Next dev HMR rebuilds (#2678)
* Optimize Next dev HMR rebuilds

* Fix Next dev HMR CI coverage

* Gate dev HMR logs behind opt-in flag

* Match workflow dev build logs to Next style

* Fix Next dev HMR changed-file classification

* Fix Windows port detection

* Relax HMR log wait in dev e2e

* Avoid canary workflow execution cache flakes

* Allow slower Turbopack HMR propagation in e2e

* Scope canary HMR fuzz execution assertions
2026-06-29 20:58:38 +00:00
JJ Kasper 55e211086d Fix discovery of dotted JS workflow imports (#2594) 2026-06-23 18:25:05 -07:00
JJ Kasper 3fd4cc5f3a Reduce workflow build log noise (#2565)
* Reduce workflow build log noise

* Label subsequent workflow builds as rebuilds

* apply suggestions from review
2026-06-23 21:59:40 +00:00
Karthik Kalyan 2bf5257f97 Trace /flow route initialization (#2592)
* Trace flow route initialization

* Add flow route module timing markers

* Address flow route tracing review feedback
2026-06-23 14:28:24 -07:00
JJ Kasper 5291f1549f Optimize and fix the default eager build mode (#2546) 2026-06-22 14:47:39 -05:00
JJ Kasper 57cccaf373 Remove lazy discovery from workflow/next (#2545) 2026-06-22 13:14:35 -05:00
Peter Wielander b713d8417b [builders] Fix unicode-escape crash in workflow graph extraction (#2324)
Signed-off-by: EfeDurmaz16 <efebarandurmaz05@gmail.com>
Co-authored-by: EfeDurmaz16 <efebarandurmaz05@gmail.com>
2026-06-19 20:04:27 -07:00
Pranay Prakash 37312edd0a Default source maps to dev-on / prod-off (#2529)
* Default source maps to dev-on / prod-off

Inline source maps are embedded in the step bundle and the intermediate
workflow VM bundle, which bloats production function bundles (a problem for
the Vercel 250MB limit) even though maps only help when reading a stack trace.

Make the default environment-aware in @workflow/builders: inline in
development (next dev / nitro dev / Vite-based dev servers, detected via
config.watch or NODE_ENV=development) and off in production. The `sourcemap`
config option and `WORKFLOW_SOURCEMAP` env var still override in either
environment. A production build with no override also drops the
source-map-support shim from the Vercel step function.

Keep runtime stack remapping graceful and fast when maps are absent
(@workflow/core): short-circuit when no frame references the workflow file
and memoize the parsed map (or its absence) per bundle, so production failures
don't rescan the bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): make source-map expectations match dev-on/prod-off default

The e2e error-stack tests gate source-map assertions on hasWorkflowSourceMaps()
and hasStepSourceMaps(). Now that source maps default to off in production
builds, update those helpers:

- hasWorkflowSourceMaps(): false for all production builds (local prod,
  postgres, Vercel — keyed off DEV_TEST_CONFIG), and exclude nest in dev (the
  Nest integration builds with watch:false / no NODE_ENV=development, so its
  bundles have no maps).
- hasStepSourceMaps(): nest now resolves to a production build (maps off) in
  both dev and prod.

Add unit cases for the dev-vs-prod and nest behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:28:26 -07:00
Nathan Rajlich 744024458f Fix Next workflow module specifier root (#2455) 2026-06-17 13:47:07 -07:00
JJ Kasper c48d27b4f8 Add .swc gitignore handling to builder (#2427) 2026-06-15 16:05:42 +00:00
Will Sather 4670c4b92d feat(core): add optional namespace for queue topic prefix (#2305)
* feat(core): add optional namespace for queue prefix

* fix(world-postgres): job queue prefix validation

* fix: changeset description

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Will Sather <56037657+willsather@users.noreply.github.com>

* fix: add world-postgres to changeset

* fix: world-postgres handle namespaced job queue names

* fix: resolve namespace via env var in core runtime

* fix: world-postgres job queue name task handler

* fix(world-postgres): honor namespace on consumer side

* Fix namespaced queue routing reliability (#2340)

* Fix namespaced queue routing reliability

* Inline queue namespace in generated routes

* Avoid loading Vercel functions during runtime import

---------

Signed-off-by: Will Sather <56037657+willsather@users.noreply.github.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: JJ Kasper <jj@jjsweb.site>
2026-06-10 20:21:17 -07:00
Pranay Prakash f5f6d0ede6 Validate unique workflow step IDs at build time (#2018)
* Validate unique step ids at build time

* Fall back to file-path IDs for non-exported package files

Instead of synthesizing a 'name/dist/<path>@version' specifier (which
hardcoded the dist/ output convention), non-exported workspace/node_modules
files now return moduleSpecifier: undefined and let the SWC plugin's
'./{filepath}' fallback produce per-file IDs. This is the same path local
app files have always taken and avoids the dist/ assumption flagged in
review. The build-time duplicate-ID check stays as the safety net.

* Dedupe virtual-entry imports by canonical module identity

When both the source and the compiled-dist copies of the same workspace
package export end up in discoveredSteps/discoveredWorkflows (e.g. the
'workflow' package's internal/builtins in monorepo dev), they resolve to
the same module via esbuild's package resolution. The virtual entry was
emitting BOTH 'import "workflow/internal/builtins";' (the built-in
preamble) and 'import "../../packages/workflow/src/internal/builtins.ts";'
(via the isWorkspaceSourceBackedPackageFile carve-out in createImport),
which made the swc plugin transform both copies and generate duplicate
step IDs.

Track a per-bundle set of emitted module identities (package specifier
when reachable, otherwise the file path) and skip files whose identity
has already been imported. The steps bundle pre-seeds the set with the
built-in steps specifier so workspace step files at that path don't
emit a competing relative-path import.

* Stop rewriting workspace package /dist/ -> /src/ during Next.js discovery

The Next.js deferred builder's `resolveSourceBackedPackagePath` rewrote
any discovered `/dist/` path to its `/src/` sibling for workspace
packages and for `workflow`/`@workflow/*` tarballs. That made the
discovered step file list point at source files while base-builder's
esbuild bundle (which builds the workflow VM and step registrations)
resolved the same package imports through `pkg.exports` to
`/dist/`. The workflow proxy ID — generated from the dist path —
didn't match the step bundle's registration ID — generated from the
src path — producing "Step function not registered" failures at
runtime, most visibly with @workflow/ai's doStreamStep on Vercel and
Windows Next.js deployments.

App code that imports a package by name should resolve naturally
through pkg.exports; the loader has no business reaching into the
package's source tree. Drop the rewrite (and the now-unused
`resolveCopiedStepImportTargetPath` helper that supported it).
Workspace packages are still discovered — that's a separate predicate
(`shouldPreferSourceBackedPackagePath`) which only gates inclusion,
not path translation.

Verified locally with the nextjs-turbopack workbench: agent e2e suite
(19 tests, including the failing `agentBasicE2e`) and the
addTenWorkflow duplicate-name suite all pass.

* Address review nits: extract stripPackageVersion, expand duplicate-ID hint, note new build-time check in changeset

---------

Co-authored-by: Nathan Rajlich <n@n8.io>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-06-10 12:59:12 -07:00
Rihan Arfan 55e17656ba fix(builders): exclude .nitro/ from input file discovery (#2109) 2026-05-28 08:58:26 +00:00
Pranay Prakash 0d0bb013d7 Generate local gitignore when using public workflow manifests (#1683)
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-05-22 15:22:25 +02:00
JJ Kasper 96e92c6370 Fix Nitro dev loading of local step dependencies (#1965)
* Fix local step dependency bundling

* Limit local dependency bundling to Nitro dev
2026-05-11 11:09:15 -07:00
Nathan Rajlich 0c997ce571 Auto-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 case
2026-05-05 09:10:30 +00:00
JJ Kasper 00a011dee4 Add stable Next.js eager and lazy test coverage (#1747)
* Add stable Next.js eager and lazy test coverage

* Address PR review feedback

* Fix eager Next step route builds

* Fix eager Next manifest refreshes

* Fix eager Next e2e stack assertions

* Externalize native step bundle bindings

* Lazy load Vercel world runtime

* Fix Next dev step sourcemap assertions

* Consolidate eager build changesets

* Fix Vercel world tracing in Next deployments

* Externalize Vercel world in Next builds

* Fix webpack tracing for Vercel world deps

* Fix eager workflow route bundling

* Rely on Next server externals
2026-05-04 21:09:20 +00:00
Peter Wielander 6dd5c72d8a Allow disabling step sourcemap with new sourcemap option in builders (#1842) 2026-05-04 11:00:15 +00:00
Peter Wielander 8ea1532e48 [core] Combine flow+step bundle and process steps eagerly (#1338) 2026-05-04 09:53:02 +00:00
Pranay Prakash 1203dae70c Friendlier workflow errors (consolidated) (#1849)
* Introduce structured context-violation errors + Ansi renderer

Phase 1: Add Ansi rendering helpers (frame, hint, note, help, code, inline)
to @workflow/errors, and a chalk mock for readable snapshot tests.

Phase 2: Add four context-violation error classes to @workflow/core
(NotInWorkflowContextError, NotInStepContextError,
NotInWorkflowOrStepContextError, UnavailableInWorkflowContextError)
and apply them to all twelve user-facing throw sites so errors now
include docs links and a structured "what/why/fix" frame.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Address review: tighten changeset, implement ansifyName, harden Ansi

- Tighten phase 1 changeset to a single sentence (per pranaygp review) and switch to double-quoted frontmatter (per Copilot + repo convention).
- Implement `ansifyName` to actually apply dim styling to workflow/ / step/ prefixes; add an `Ansi.dim` helper to `@workflow/errors` so callers don't need to import chalk directly.
- Remove the `void getWorkflowMetadata;` workaround in context-errors.ts by dropping the unused value import (we only needed the type and symbol).
- Render the plain-Error throw in `workflow/get-workflow-metadata.ts` with `Ansi.frame` + docs link so the VM path matches the structured-class styling from the sibling step path (still uses a plain Error to avoid the module-init cycle).
- Guard `buildUnderline` against zero-length markers so a stray empty token can't produce a negative `String.repeat` count.

* Structured runtime logger metadata + fold in replay-timeout logging

Adds a `.child()` and `.forRun(runId, workflowName)` child-logger API to
the structured logger so runtime/step code doesn't have to repeat
`workflowRunId`/`workflowName`/`stepId` on every call. Normalizes error
metadata to structured `errorName` / `errorMessage` / `errorStack` fields
instead of ad-hoc `error: err.message` strings, and adds comments to
silent catches that swallow expected idempotency conflicts.

Also folds in the pending changes from #1812 so that PR can be closed:

- Standardize the console prefix to `[workflow-sdk]`.
- Split the replay-timeout log into a warn-while-retrying vs.
  error-when-giving-up, and surface the underlying error when we can't
  mark a timed-out run as failed.
- Include the error stack in the "Fatal runtime error during workflow
  setup" log and in the top-level user-code workflow error log so the
  stack surfaces in flattened log drains.
- Drop the `[Workflows] "<runId>" - ` prefix from
  `buildWorkflowSuspensionMessage` — the structured logger now attaches
  run context.

Supersedes #1812.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Use double-quoted changeset frontmatter per repo convention

* Add SerializationError + apply to user-facing serialization sites

Phase 4 of friendlier errors: introduce a `SerializationError` class with
an optional `hint` and a docs link (workflow-sdk.dev/err/serialization-failed),
and adopt it at every user-facing serialization boundary in @workflow/core:

- Locked ReadableStream at a workflow boundary
- Unregistered class / missing `classId` / missing `WORKFLOW_DESERIALIZE`
- Attempting to return step functions to clients or call workflow functions
  directly
- Webhook `respondWith()` called outside a step
- `dehydrate*` / `getSerializeStream` failures (workflow args/return, step
  args/return, stream chunks)

Internal invariants (format prefix length checks, unknown format bytes,
missing `STREAM_NAME_SYMBOL`, encryption key/size guards, etc.) now throw
`WorkflowRuntimeError` instead of plain `Error` so the classifier and logger
treat them consistently.

`formatSerializationError` now returns `{ message, hint }` so the hint
fragment can be rendered with the standard SerializationError framing
instead of being baked into the message string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Use double-quoted changeset frontmatter per repo convention

* Presentation-only user vs SDK error attribution

Add describeError() that derives attribution and class-aware hints from
existing error classes + RUN_ERROR_CODES — no event data changes. Wire into
step failures, max-delivery exhaustion, run failures, and fatal setup errors
so terminal logs include errorAttribution and a hint for known error types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Address review: describeError accepts precomputed errorCode + instanceof

- `describeError(err, errorCode?)` now accepts an optional precomputed
  `RunErrorCode`. `classifyRunError(err)` only narrows to USER_ERROR /
  RUNTIME_ERROR, so the REPLAY_TIMEOUT and MAX_DELIVERIES_EXCEEDED branches
  were previously unreachable from the step / run failure log sites.
  Callers that know the failure category (runtime.ts for replay timeout and
  max-deliveries exhaustion) now pass the code in.
- Context-violation checks use `instanceof` against the actual classes from
  context-errors.ts instead of a name-string set. Type-safe + survives
  class renames.
- Wire the new hints through to the REPLAY_TIMEOUT and MAX_DELIVERIES_EXCEEDED
  log sites so those branches actually render a hint now.
- 3 new tests cover the reachable code paths + precomputed-code override.
- Changeset frontmatter switched to double quotes per repo convention.

* Cosmetic consistency pass on remaining bare throws

Internal invariants now use WorkflowRuntimeError so describeError attributes
them to the SDK: missing startedAt, VM generateKey, closure-vars outside
step context, ENOTSUP. defineHook().resume() formats schema validation
failures as a readable list instead of a JSON blob.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Use double-quoted changeset frontmatter per repo convention

* Data-driven describeRunError + expose via @workflow/core/describe-error

Observability renderers read persisted run_failed / step_failed event data,
not live Error instances. describeRunError takes { errorCode, errorName }
and returns the same { attribution, hint } shape as describeError, so the
CLI and web UI can derive user-vs-SDK framing from the event log directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Friendlier build-time errors: WorkflowBuildError class + applications

Add `WorkflowBuildError` class in `@workflow/errors` with optional `hint`
for an actionable next step, and apply it in `@workflow/builders` at
user-facing sites: failed esbuild phases, unresolved built-in steps, and
empty esbuild output now throw `WorkflowBuildError` with a hint pointing
at the likely fix. Runtime invariants remain plain `Error`.

* Polish friendlier-errors rendering: drop functionName leak, simplify docs link, redirect stack

- Drop the readonly `functionName` param-property on context-error classes so
  util.inspect no longer prints a trailing `{ functionName: 'foo()' }` block.
- Replace the `DocLink` ("label: https://…") shape with a plain `DocsUrl`
  template-literal type. Error output now renders a single clean line:
  `docs: https://…` (new `Ansi.docs` helper) instead of the noisier
  "note: Read more about foo(): https://…".
- Add throw helpers (`throwNotInWorkflowContext`, etc.) that call
  `Error.captureStackTrace(err, stackStartFn)` on V8 engines so the top frame
  of the thrown error points at the user's call site instead of at the gate
  function inside the framework. Callers pass themselves as the boundary.
- Refactor `defineHook()` (both root and `/workflow`) to use named function
  closures rather than `this.create`/`this.resume`, since the stack redirect
  relies on a stable function identity that survives destructuring.
- Update context-errors.test.ts to snapshot the new `docs:` framing and to
  add a regression test asserting the top stack frame is the user call site.

* Consolidate friendlier-errors stack: fix ANSI leak + non-retry semantics

Addresses PR review feedback across the 8-phase friendlier-errors stack and
fixes issues surfaced by manual testing (createHook() inside a step):

- ANSI no longer leaks into .message / .stack. Context-violation errors
  now store plain text on .message and render the colored framed form
  lazily via [util.inspect.custom] / toString(). Structured logs, log
  drains, CBOR-serialized events, and JSON payloads no longer contain
  raw \x1B[...m bytes.

- Context violations are now fatal. ContextViolationError sets
  fatal = true; FatalError.is(err) recognizes any error with a
  fatal: true own property. Calling createHook() from a step no longer
  burns three retry attempts on a guaranteed-to-fail context violation.

- Ansi helpers moved to @workflow/errors/ansi subpath so imports from
  @workflow/errors no longer pull chalk into consumers that only want
  error classes (addresses reviewer VaguelySerious).

- Shared redirectStackToCaller helper in packages/core/src/capture-stack.ts,
  used by both context-errors.ts and workflow/get-workflow-metadata.ts
  (addresses Copilot review on #1849).

- Structured framed content: ContextViolationError now takes a structured
  FramedContent (title segments + detail branches) and renders plain/pretty
  from the same source of truth.

Tightens the eight existing phase changesets to 1-2 sentences each and adds
four new scoped changesets (errors-ansi-subpath, context-errors-plain-message,
context-errors-fatal, capture-stack-shared) for the followup fixes, so the
final changelog history stays readable.

* test: update step-handler mocks for scoped forRun() logger

The runtime logger now uses .forRun(runId, name, {stepId, stepName})
to attach scope context, so 409-handling log calls no longer repeat
{workflowRunId, stepId} in every metadata bag — those live on the
scoped logger instance. Update the mock to return itself from forRun()
and tighten assertions to check both the log args (errorName/errorMessage)
and the forRun() scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Mark SerializationError fatal + route dehydration through step-failure path

SerializationError now carries readonly fatal = true. Step-return
dehydration is wrapped inside the user-code try/catch so that the
resulting error flows through userCodeFailed → step_failed →
FatalError.is() short-circuit instead of bubbling up as HTTP 500 and
triggering a queue retry loop. Retrying a step that returned a non-POJO
is guaranteed to fail the same way, so this saves ~20s and 3 near-
identical error blocks per serialization failure.

* Add logging snapshot tests + manual-test artifacts

Snapshot tests lock in the exact shape of:
- describeError() payloads (attribution, errorCode, hint) for every
  classification — plain Error, SerializationError, context-violation,
  WorkflowRuntimeError, REPLAY_TIMEOUT, MAX_DELIVERIES_EXCEEDED.
- The scoped-logger call signature for the two canonical runtime
  failure paths (fatal-bubble and hit-max-retries), so refactors of
  forRun() / child() metadata merging can't silently change what users
  see in their log drains.

SerializationError now also has a direct test for readonly fatal=true
+ FatalError.is() recognition.

pr-artifacts/ contains real log-output snapshots from running the
nextjs-turbopack workbench against five error scenarios. These are
reference material for reviewers and are flagged to be removed before
merge.

* Readable step-fatal logs: inline stack + friendly step/workflow names

The step-level fatal-error log used to embed the full stack trace inside
an `errorStack` string field in the metadata object, so util.inspect
rendered it as a quote-escaped, line-continuation blob when the log
hit the terminal — unreadable in practice. Move framing + stack into
the log *message* (matching the workflow-level log in runtime.ts) and
keep the metadata object compact with only the indexable structured
fields (`errorAttribution`, `errorName`, `errorMessage`, `hint`,
IDs). Log drains still get the same keys; humans now see a readable
stack trace.

Also introduce `formatStepName` / `formatWorkflowName` in
`@workflow/utils` that render machine names
(`step//./workflows/1_simple//add`) as `add (./workflows/1_simple)` in
log framings, using the existing `parseStepName` / `parseWorkflowName`
parsers. Applied to step-fatal, hit-max-retries, exceeded-max-retries,
and workflow-threw log sites.

Artifacts in pr-artifacts/ updated to show the new output shape, and
renamed .log → .md since they're Markdown and IDE previews are nicer
that way.

* Opinionated pretty formatter for runtime structured-log metadata

Replace util.inspect's default object dump (which quote-escapes
multi-line stacks and paragraph hints into a single-line JSON-y blob)
with a workflow-aware formatter that composes the entire log line
into a single string passed to console.error / console.warn.

Highlights of the new output:
- Per-run / per-step IDs render with their parsed friendly names so
  users see `wrun_… · simple (./workflows/1_simple)` instead of just
  the raw `workflowName: 'workflow//./workflows/1_simple//simple'`.
- Color-coded attribution badge (user error red / sdk error magenta)
  paired with the error class in bold.
- Hints render as a paragraph under `hint:` rather than a backslash-
  `\n`-escaped string.
- Drops redundant fields (errorStack always; errorMessage when it's
  already in the parent message) to avoid double-printing.
- Unknown fields fall through as a sorted `key  value` tail so we
  never silently drop log information.

@workflow/errors/ansi gains bold/red/magenta helpers used by the
formatter. The web / web-shared packages don't consume stderr — they
read structured event payloads from the World event log — so this is
presentation-only at the runtime layer.

* ci(benchmarks): disable pnpm cache for getCommunityWorldsMatrix

The job never runs `pnpm install` (it just calls `node` against a
checked-in script), so the pnpm store path never exists. The post-job
`actions/setup-node@v4` cache-save then fails with `Path Validation
Error: Path(s) specified in the action for caching do(es) not exist`
and red-X's the entire job even though the matrix step succeeded.

The setup-workflow-dev composite already has a `cache-pnpm` opt-out
input for this exact case — wire it through here.

* Address PR review comments: inspect dedup, cause leak, retry-loop tests

- ContextViolationError: util.inspect(err) duplicated every framed detail
  line because the stack-tail strip only sliced the first message line.
  V8's Error.stack reads `Name: messageLine1\n  messageLine2\n  at ...`,
  so for our multi-line `title\n╰▶ docs: …` messages every detail line
  was getting prepended twice (once in the pretty form, once via the
  unsliced message tail). Count the actual message lines and slice past
  all of them. Repro test asserts `╰▶ docs:` appears exactly once.

- WorkflowError: stop assigning `cause: undefined` as an enumerable own
  property when no cause is provided. Subclasses (every error in this PR)
  inherit the parent constructor; the unconditional assignment polluted
  `util.inspect(err)` output with `{ cause: undefined, … }` on every
  no-cause instance. The `super(...)` call already conditionally sets
  `.cause` non-enumerably when `options.cause` is provided.

- step-handler.test.ts: add a regression-gate suite that exercises the
  fatal-vs-retryable retry-loop wiring directly. Asserts that an error
  with `fatal: true` produces exactly one `step_failed` event with no
  `step_retrying`, and that a non-fatal `Error` retries via
  `step_retrying` on early attempts and emits `step_failed` once the
  retry budget is exhausted. Catches the silent-regression case where
  `fatal = true` is removed from a context-violation error class but
  the `FatalError.is()` unit tests stay green.

* Consolidate changesets + remove pr-artifacts

Address review feedback to drastically shorten the changesets — fold
the 15 file-by-file entries into a single user-facing changeset for
@workflow/core / errors / builders / utils. Also drop the pr-artifacts/
folder (reviewer-only log captures, no longer needed).

* Polish runtime error logging: layout, stack trim, hint consolidation

Five user-driven fixes from manual smoke-testing of #1849:

1. Logger layout. composeLogLine() now puts the structured-fields block
   (attribution badge, run/step IDs, error code) **between** the framing
   line and the stack body, instead of after it where 30+ lines of stack
   buried the most useful information. The framing stays at the top,
   stack at the bottom, structured info readable at a glance.

2. Stack trim. Drops framework-internal frames (`node_modules/.pnpm/`,
   `node:internal/`, Turbopack-bundled `node_modules__pnpm_*` chunks,
   `_next_dist_*` chunks) and caps the surviving frame count at 6
   so the stack stays compact even on heavy async wrappers. Suppressed
   runs emit one summary line so users know the trim happened.

3. Wrapper-route noise. The nextjs-turbopack workbench's start route
   was catching `WorkflowRunFailedError` rejection on
   `Promise.race([readLoop(), run.returnValue])` and re-logging it via
   `console.error('Error in workflow stream:', error)` plus
   `controller.error(error)` — which then triggered Next.js's
   `⨯ failed to pipe response` overlay. The SDK already logs the
   failure cleanly upstream and the runId is on the response header, so
   the wrapper now closes the SSE stream cleanly on
   WorkflowRunFailedError.

4. Consistent framed `╰▶ hint:` / `╰▶ docs:` layout for all errors
   that carry a hint or docs slug. WorkflowError, SerializationError,
   and WorkflowBuildError now share one `appendFramedDetails` helper
   matching the box-drawing structure that ContextViolationError
   already used. Was: blank-line-separated `Learn more: <url>`. Now:
   one tree, indistinguishable from context-violation rendering.

5. Drop the duplicate logger-side `hint` field. Hints now live on the
   error message only — actionable hints get serialized into the event
   log, rehydrated on the workflow side, and shown in observability
   automatically. The previous logger-only hint duplicated stderr but
   never made it past the step boundary.

   Updated SerializationError hint to point at the foundations doc
   ("Ensure you're returning workflow serializable types. Check the
   serialization docs to see what's serializable:
   https://workflow-sdk.dev/docs/foundations/serialization") instead
   of the hardcoded `(plain objects, arrays, primitives, …)` list,
   which drifted out of sync as the supported types grew. Same hint
   reuses for step args, workflow args/return, stream messages, and
   any other site that goes through `formatSerializationError`.

Also retitled the retry summary `3 retries` → `3 max retries` since
"3 retries" next to "4 attempts" was ambiguous (already-happened vs.
budget).

* Trim error-card title + drop machine step name from persisted error

- ErrorStackBlock (web observability): show just the first non-empty
  trimmed line of the error message in the card title with single-line
  truncation. Multi-line messages (`Failed to serialize step return
  value\n╰▶ hint: …`) were rendering the entire framed body in the
  title, pushing the copy button off-screen and burying the
  scannability of the headline. Full message stays in the body via
  the stack (V8 prepends `Name: message` to `Error.stack`), so no
  information is lost; hover-tooltip exposes the full title text.

- Persisted error message: drop the `Step "step//./.../foo"` machine
  name from `Step failed after N retries: …` and `Step exceeded max
  retries (…)` strings. Observability already attributes the event
  to a specific step via the UI tree, and the CLI logger emits the
  friendly `Step foo (./...) hit max retries` framing on its own
  line. Embedding the raw `step//./...` machine name in the persisted
  message text was duplicate noise.

* Update .changeset/friendlier-errors.md

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* Update .changeset/pretty-log-format.md

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* Update SerializationError snapshot tests for slug-less message

The class no longer attaches a slug-based `╰▶ docs:` line — the
foundations URL is embedded directly in the hint via the
`formatSerializationError` helper in @workflow/core. Update the test
expectations accordingly:

- bare-title case is now a single line (no docs link)
- hint case renders one `╰▶ hint: …` branch (no second branch)

* Update serialization.test.ts hint assertions for foundations URL

Four `should throw error for an unsupported type` cases were still
asserting on the old hardcoded type list. Update to the new hint
phrasing that points at the foundations doc, matching the change in
`formatSerializationError` (`packages/core/src/serialization/errors.ts`).

---------

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-05-04 05:04:24 +00:00
Nathan Rajlich 78301695ea fix(builders): avoid false-positive node-module errors for step-only usage in shared modules (#1821)
The workflow-node-module-error plugin records violations in onResolve, which
runs before esbuild's tree-shaker. A shared module with both a workflow-safe
export and a step-only export that imports node:* would be flagged even when
the workflow never reached the step-only export.

Mark node:/bun: imports as sideEffects: false so esbuild can tree-shake them
when unused in surviving code, enable metafile, and filter recorded violations
in onEnd against outputs[].imports so only builtins that actually survive in
the emitted bundle are reported.

Fixes #1817
2026-05-01 17:57:56 +00:00
Nathan Rajlich e0ec429bb3 fix(builders): bundle aliased project-local helpers instead of externalizing (#1885)
Externalizing aliased helpers was unsafe: their source on disk could
contain further alias imports that Node's ESM loader doesn't know about,
causing 'Package subpath ... is not defined by "exports"' /
ERR_MODULE_NOT_FOUND at runtime.

Now bundle inline so all alias resolution happens at build time.
2026-05-01 02:20:31 -07:00
JJ Kasper baba580794 Write Next diagnostics to dist dir (#1857)
* Write Next diagnostics to dist dir

* Simplify Next dist dir config access
2026-04-28 22:02:03 +00:00
Nathan Rajlich 417c4930be refactor(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.
2026-04-16 22:50:46 +00:00
Nathan Rajlich 173756dc4d [docs] Rename workflowdevkit to workflowsdk and useworkflow.dev to workflow-sdk.dev (#1759)
* [docs] Rename workflowdevkit references to workflowsdk

* [docs] Rename useworkflow.dev to workflow-sdk.dev

* [chore] Add changeset for domain rename

* [docs] Revert sitemap rewrite to useworkflow.dev (crawled-sitemap not yet available for new domain)
2026-04-15 18:01:48 -07:00
Nathan Rajlich e788e3b41c fix(builders): restore discovery WeakMap cache for dev rebuilds (#1699)
Move `workflow/runtime` resolution from `createStepsBundle` into
`discoverEntries` so callers pass the original `inputFiles` array
reference. This restores the WeakMap cache hit when
`createWorkflowsBundle` and `createStepsBundle` are called with
the same inputFiles, avoiding a redundant second esbuild discovery
pass per build that was causing E2E Local Dev Tests to flake on CI.
2026-04-13 11:54:45 -07:00
Nathan Rajlich dc0c0dce7f fix(builders): improve step bundle discovery and externalization for SDK serde classes (#1669)
* fix(builders): improve step bundle discovery and externalization for SDK serde classes

- Broaden importParents tracking to all imports (not just file extensions)
  so parentHasChild() works through bare specifier imports
- Include workflow/runtime in discovery inputs so SDK serde classes like
  Run are always discovered
- Bundle node_modules deps instead of externalizing with broken relative
  paths in CJS shims

* fix(builders): address review feedback

- Preserve inputFiles array reference for discoverEntries() WeakMap
  caching when no extra entries are needed
- Add test verifying importParents/parentHasChild works through bare
  specifier imports (entry -> bare-pkg -> serde file)
2026-04-09 19:37:38 +00:00
Nathan Rajlich 89d242fae2 Remove isWorkflowSdkFile serde exclusion (#1662)
* Remove isWorkflowSdkFile serde exclusion

The SWC detect mode's AST-level manifest (hasManifestEntries) already
correctly filters files without serde class definitions. The broad
isWorkflowSdkFile path exclusion is redundant and was preventing
class definitions in SDK packages from being discovered.

Removed from: builders, next, rollup

* Address review feedback: major semver bump, fix doc comment, add tests

- Bump changeset to major (removing exported APIs is breaking)
- Fix misleading doc comment referencing 'detect mode' in shouldTransformFile
- Add unit tests for shouldTransformFile covering all code paths

* Add AST-level serde filtering to Next.js deferred builder

The isWorkflowSdkFile path-based exclusion was removed from all paths,
but the Next.js deferred builder (collectTransitiveSerdeFiles) was still
using regexp-only detection, causing SDK internal files to be bundled
into the workflow sandbox and triggering stack overflows.

Fix: add applySwcTransform('detect', ...) verification at the end of
collectTransitiveSerdeFiles(). Regex-matched serde candidates are now
verified via the SWC plugin's AST-level manifest to confirm they
actually define serde classes before inclusion in the bundle.
2026-04-08 18:07:39 -07:00