Skip to content

feat: serverless typescript sdk - #4949

Draft
abelanger5 wants to merge 64 commits into
belanger/serverless-operatorfrom
belanger/serverless-ts-core
Draft

abelanger5 wants to merge 64 commits into
belanger/serverless-operatorfrom
belanger/serverless-ts-core

Conversation

@abelanger5

Copy link
Copy Markdown
Contributor

(WIP)

Description

Fixes # (issue)

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • Documentation change (pure documentation change)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (non-breaking changes to code which doesn't change any behaviour)
  • Performance improvement (non-breaking change which improves performance)
  • CI (any automation pipeline changes)
  • Chore (changes which are not directly related to any business logic)
  • Test changes (add, refactor, improve or change a test)
  • This change requires a documentation update

Checklist

Changes have been:

  • Documented (where applicable)
  • Added to CHANGELOG (where applicable) -- see Keep a Changelog

🤖 AI Disclosure
  • I acknowledge that an LLM was used in the creation of this Pull Request, in accordance with Hatchet's AI_POLICY.md.
  • Details: [e.g. generating tests, writing docs]

abelanger5 and others added 30 commits September 8, 2026 14:54
`computeMemoKey` now hashes with `crypto.subtle.digest('SHA-256', ...)`
instead of Node's `createHash`, so `DurableContext` no longer needs the
`crypto` builtin. The function is async and exported; `memo()` (the only
caller, behind `now()`) awaits it. The digest covers the same bytes as
before (task run id followed by the JSON-serialised dependencies), which
the new unit test proves against the old implementation, so durable event
logs recorded by earlier SDKs keep replaying.

Runtimes without `globalThis.crypto` get a clear HatchetError naming the
Node 20 requirement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
`Context` and `DurableContext` no longer reach into `HatchetClient` and
`InternalWorker`. They depend on two seams defined in the new, Node-free
`v1/client/worker/runtime.ts`:

- `ContextRuntime`: a logger factory, the namespace, and the engine-facing
  operations a context performs (cancelRun/cancelBatch, putLog,
  refreshTimeout, releaseSlot, putStream, runWorkflow(s)) plus the worker
  facts `ctx.worker` reports (workerId, hasWorkflow, workerLabels,
  upsertWorkerLabels).
- `DurableTransport`: the six listener members a durable task talks to
  (sendEvent, waitForCallback, consumeCallbackWithoutBlocking,
  sendMemoCompletedNotification, cleanupTaskState, sendEvictInvocation).

The worker supplies adapters over its client and durable listener
(`worker-runtime.ts`); the existing `(action, client, worker[, listener,
...])` constructors remain as overloads and build the adapter, so the
worker's call sites and tests are unchanged. `ctx.v1` is still set when a
context comes from a worker. A `DurableContext` on a transport without the
legacy unary RPCs gets a clear error instead of a crash when the engine
predates eviction.

To keep the context module free of Node imports on the way:

- `Action`, `ActionKey`, `createAction`, `workflowNameFromAction` and a
  new `createActionId` move to the pure `clients/dispatcher/action.ts`;
  `action-listener.ts` re-exports them.
- The durable ack/event shapes move to `durable-events.ts`; the listener
  client re-exports them.
- `bindAbortSignalHandler` (the only `events` user) moves from
  `util/abort-error` to `util/abort-signal`.
- `ParentRunContextManager` gets a pluggable store; the default store has
  no context, and the worker installs the `AsyncLocalStorage` store at
  module load through `parent-run-context-storage.ts`, so worker semantics
  are unchanged while declaration code no longer imports `async_hooks`.
- `util/logger` and `dispatcher-client` import their types from leaf
  modules instead of the `v1` barrel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
`registerWorkflow` built the `CreateWorkflowVersionRequest` inline and
called `putWorkflow` in the same breath. The request construction now
lives in the pure `v1/client/worker/workflow-proto.ts`:

- `normalizeWorkflowDefinition(definition, { namespace, durable })`
  applies the namespace, lowercases the name and appends the on-success
  task as a regular task with the DAG leaves as parents. It returns a new
  definition (the declaration is no longer mutated) and is idempotent.
- `workflowToProto(definition, { namespace, durable })` accepts a
  declaration or a definition and returns the request. The mapping helpers
  (`mapWorkerLabelPb`, `mapSlotRequestsPb`, `mapRateLimitPb`,
  `mapConcurrencyPb`, `mapBatchConfigPb`, `mapStickyStrategyPb`,
  `taskConcurrencyArr`, `resolveExecutionTimeout`,
  `resolveScheduleTimeout`, `onFailureTaskName`, `getLeaves`) are
  exported from it; `worker-internal.ts` re-exports the ones it exported
  before.

The worker normalizes once, registers through `workflowToProto`, and keys
its registries by the same normalized definition.

Action ids are unified on one canonical form, `createActionId`:
`<workflow>:<task>` fully lowercased. Registration previously kept the
task name's case while the worker's action registries lowercased it; the
engine lowercases on insert (`workflows.sql`) and the Go SDK sends
lowercase, so the wire form is unchanged for the engine and mixed-case
task names now register the id the worker actually serves. A unit test
registers a workflow with mixed-case task names through a stubbed
`putWorkflow` and checks the registered ids equal the registry keys.

The fixture test covers standalone, durable, DAG with parents and
conditions, on-failure and on-success in both forms, event and cron
triggers with a namespace, input validator to JSON schema, concurrency
(array and single), rate limits, sticky, worker labels, default filters,
idempotency and task defaults, and checks that `toJSON` yields protojson
(lowerCamelCase keys, base64 bytes) that round-trips.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
Adds `@hatchet-dev/typescript-sdk/edge` (`src/edge/index.ts`): the
declaration factories and classes with their option types, `task.ts`,
`types.ts`, durations, conditions, `applyNamespace`, `createAction`,
`createActionId`, `workflowNameFromAction`, `Context`, `DurableContext`,
the `ContextRuntime` and `DurableTransport` interfaces with their event
types, `HatchetError`, `NonDeterminismError`, `TaskRunTerminatedError`,
the abort helpers, eviction policy types, `workflowToProto` and the wire
types it needs (`CreateWorkflowVersionRequest`, `AssignedAction`,
`DurableTaskRequest`, `DurableTaskResponse`). `declarations()` returns
`{ task, durableTask, workflow, batchTask }` bound to no client, mirroring
the `HatchetClient` factories.

`scripts/check-edge-entry.mjs` bundles `dist/edge/index.js` with esbuild
(`platform: 'browser'`, `conditions: ['workerd', 'worker', 'browser']`)
and fails on any `node:` specifier or Node builtin reached transitively.
The two agent SDKs are external: `mcpTool()` requires them lazily and they
are optional peers. The entry currently bundles 40 SDK modules and only
`@bufbuild/protobuf` and `zod`. `pnpm check:edge` builds and runs it; the
typescript workflow's lint job runs it after the type check.

package.json gains an `exports` map written for the published layout
(publishing happens from inside `dist`): explicit entries for `.`,
`./edge`, `./v1`, `./v1/embedded`, `./package.json` and every directory
that has an `index.ts` (Node stops resolving directory indexes once
`exports` exists), plus `./*.js` and `./*` passthroughs so deep file
imports such as `util/sleep` or `protoc/v1/workflows` keep working.
`main` and `types` now point at `index.js` / `index.d.ts`; the previous
`dist/index.d.ts` did not exist in the published package. A unit test
keeps the map in step with the `index.ts` directories.

Verified by `pnpm pack` from `dist` and installing the tarball into a
scratch project: CJS `require` and ESM `import` of the root, `/edge`,
`/v1`, `/v1/embedded`, `/protoc/v1/workflows`, `/protoc/dispatcher`,
`/util/sleep`, `/util/sleep.js`, `/v1/conditions`, `/util/logger` and
`/package.json` all resolve, and `tsc` resolves the types under `node16`,
`bundler` and `node10`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
Bumps the TypeScript SDK to 1.32.0, adds the changelog entry for the edge
entry point, the exports map, `workflowToProto`, the runtime interfaces,
the action id casing change and the WebCrypto memo keys, syncs it into the
docs changelog, and regenerates the TypeScript reference docs (the
`Context` page picks up the runtime constructors).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
`@hatchet-dev/typescript-sdk/edge` resolves the same way every other
subpath of the package does: the package publishes from inside `dist`,
so `edge/index.js` and `edge/index.d.ts` sit at the published root and
Node, TypeScript and bundlers find them by file layout, exactly as they
find `v1/embedded`, `protoc/v1/workflows` or `util/sleep`. `package.json`
carries no `exports` map and no `main`; `types` is `index.d.ts`, which is
the file that exists at the published root.

Consumer behaviour, checked against the packed tarball: CJS `require`
resolves the root, `/edge`, `/v1`, `/v1/embedded`, `/protoc/dispatcher`,
`/protoc/v1/workflows` and `/util/sleep`; `tsc` resolves their types
under `node10`, `node16` (CommonJS) and `bundler`. Native ESM needs the
full file path (`/edge/index.js`, `/v1/embedded.js`), which is the rule
for every subpath of this package.

`scripts/check-edge-entry.mjs` and `pnpm check:edge` still enforce that
the edge entry reaches no Node builtin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
…langer/serverless-ts-core

# Conflicts:
#	go.mod
Turns sdks/typescript-serverless from a generation target into a
buildable package: tsup produces ESM and CJS with declarations for the
root, ./cloudflare and ./testing entries, the exports map lists types
first for each, and the runtime entries are checked by
scripts/check-edge-entry.mjs, which bundles them for a workerd-like
target and fails on any Node builtin or any SDK module outside the edge
entry.

The package depends on @hatchet-dev/typescript-sdk 1.32 for declarations
only. 1.32 is unreleased, so development uses file:../typescript/dist, a
copy of the SDK's published layout with only its runtime dependencies
installed. A link: to the same directory would resolve the SDK's
devDependencies too, and its lazily required agent SDKs then drag Node
builtins into a Workers bundle; the copy behaves like a real install.
task generate-serverless-proto builds the SDK dist before installing for
the same reason.

The ts-proto options drop the service stubs: the package never speaks
gRPC and the stubs referenced nice-grpc types it does not depend on.
Vitest runs the unit tests; eslint and prettier mirror the SDK's config.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
createHandler turns declarations into the endpoint the serverless
operator polls and invokes: POST <basePath>/healthcheck answers the
registration (every workflow through the SDK's workflowToProto, passed
through protojson into the package's generated types, plus the served
action ids, durable.supported false and the runtime), and
POST <basePath>/trigger verifies the HMAC-SHA256 signature over the raw
body, decodes ServerlessTriggerRequest with the generated fromJSON,
strips the namespace from the action id and workflow name, runs the task
under a Context and maps the outcome the way delivery.go reads it: 200
with the output, 204 for undefined, 422 retry false for NonRetryableError
and ServerlessLimitationError, 500 retry true for anything else, 404 for
an action not served here, 401 for a bad signature and 426 for a durable
upgrade, which the next phase serves.

ServerlessRuntime is the ContextRuntime behind that Context: logging goes
to the console, every engine-facing call throws ServerlessLimitationError
naming the feature, workerId is undefined and hasWorkflow answers from
the declarations. Declaration options that need a worker (slotCost,
slotRequests, batch, desiredWorkerLabels, sticky, evictionPolicy) are
removed from the registration with one warning each; the declaration
object itself is left untouched.

The root entry exports hatchet, the SDK's declaration factories bound to
no client, together with the handler, the signature helpers (the upgrade
verification is already there for the relay) and the SDK types a task
author needs. Nothing in it imports from Node or reads process.env.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
@hatchet-dev/serverless/cloudflare exports cloudflare(options), which
returns an ExportedHandler<Env>: requests under basePath go to the
generic handler with the secret resolved per call from
env.HATCHET_SIGNING_SECRET (or the secret option) and waitUntil wired to
ctx.waitUntil for the relay to come; every other path goes to the user's
fetch option or gets a 404. Typed against @cloudflare/workers-types with
no runtime import from Cloudflare.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
@hatchet-dev/serverless/testing invokes a handler the way the operator
would, without Hatchet: createTestOperator signs each request with the
endpoint secret, namespaces the action id and workflow name with a UUID
prefix, POSTs a ServerlessTriggerRequest built with the generated
bindings, and classifies the response with the rules of delivery.go.
invoke returns the output or throws InvocationFailedError; deliver
returns the classification; request sends a raw signed request;
healthcheck decodes the body with the generated fromJSON and fails when
the body is not canonical protojson. invokeDurable throws the limitation
error until the relay lands.

The unit tests cover the healthcheck shape and the served subset, the
warnings for ignored options, every row of the outcome table, namespace
stripping, the Context accessors and the Cloudflare adapter's routing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
The README opens with the one hard limitation: no Hatchet client and no
token in the serverless runtime, why, and exactly which ctx members
throw. Then the usage from the plan (declarations, the Cloudflare
adapter, registration through the REST endpoint until hatchet serverless
register exists, tests without Hatchet), the outcome table and the
development steps. LIMITATIONS.md carries the full list, the ignored
declaration options and the serve caveat before namespaces span
endpoints.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
The example drops its hand-written contract shim (src/hatchet.ts) for
@hatchet-dev/serverless: src/tasks.ts declares echo with hatchet.task and
keeps sleep-then-echo as a durable declaration that the healthcheck
advertises but this version cannot serve, and src/index.ts is
export default cloudflare({ workflows }). The package is linked from the
repository until it is published; the README explains the build order,
the new echo input ({"message": ...}) and that the durable task waits
for the relay phase.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
A serverless job in the TypeScript workflow builds the SDK dist the
package depends on, installs the package, runs lint, the type check, the
unit tests and check:edge, then type-checks the Cloudflare example and
bundles it with wrangler deploy --dry-run so a Node builtin sneaking into
the runtime entries fails the build the way a real deploy would. The
workflow also triggers on the package and example paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
FrameTransport implements the SDK's DurableTransport over a DurableSocket:
sendEvent builds the same DurableTaskRequest the worker's
DurableListenerClient builds for memo, wait_for and trigger_runs, sends it
as a request frame with a sequence id and resolves the matching ack;
waitForCallback resolves on the entry_completed for its (branchId, nodeId)
in delivery order, with the SDK's ordered-completion rule; complete_memo
goes out without an ack; evict_invocation resolves on eviction_ack. One
ack-bearing request is in flight at a time, the rest queue locally, so a
fanned-out task never trips the operator's 4006. Error frames reject
everything pending with NonDeterminismError for code nondeterminism and
a plain Error otherwise.

The inline wait budget is measured from the ack that created an entry;
when an awaited completion outlives it the invocation evicts: evict_invocation,
its ack, done { status: evicted } as the last frame, then the task is aborted
with TaskRunTerminatedError so user code unwinds without touching the
socket. A server_evict aborts the same way without the round trip and
sends nothing more, since the operator closes with 4001.

runDurableInvocation is the runner: wait for the first frame (bounded),
strip the namespace, look the durable task up (a non-durable action over
the socket is done { error, retry: false }), build a DurableContext on the
ServerlessRuntime and the transport with the minimum engine version that
enables eviction, run the function under the parent run context, and
finish with done { output } or done { error, retry } where retry is false
for NonRetryableError and after an engine error frame. The socket is
closed with 1000 after the done frame; an operator close aborts the task
and sends nothing.

createHandler verifies the upgrade signature (with the optional nonce set)
before handing the socket to the adapter's upgrade hook, still answers 426
without one, and the healthcheck advertises durable support only for a
handler whose adapter supplies the hook and that has a durable task.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
The Cloudflare adapter supplies the upgrade hook: WebSocketPair, accept the
server side, run the invocation under ctx.waitUntil so it outlives the 101,
and answer with the client side. cloudflareSocket adapts the Workers
WebSocket to the relay's DurableSocket (text frames only, one close
notification). The adapter declares durable support, so the healthcheck
advertises it for Workers that declare a durable task.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
The test operator dials durable tasks the way the operator does: a signed
upgrade through the handler's DurableHooks.upgrade seam, an in-memory
socket pair, the first frame, and the frame protocol with a durable event
log per task run. Memo entries replay with memoAlreadyExisted, wait
entries complete when the virtual clock passes a sleep or an emitted event
matches, trigger_runs children run the registered task function through
the trigger route, and a replay whose events diverge from the log gets a
nondeterminism error frame. invokeDurable returns the outcome with every
frame that crossed the socket; startDurable exposes the invocation for
mid-flight server evictions, error frames and closes; resume re-invokes
against the same log with the next invocation count.

The fake enforces the operator's rules so a misbehaving endpoint fails a
test: one ack-bearing request in flight (4006), nothing after done (4005),
done evicted only after an eviction ack (4005), JSON output only.

Tests cover the frame sequences for an inline sleep, an eviction and its
resume with the memoized value, waitForEvent, spawnChild, server eviction,
engine error frames, a diverging replay, an operator close mid-task, the
failure mapping, a non-durable action over the socket, the upgrade
signature checks and the healthcheck's durable flag, with fake timers and
a check that no timer survives a case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
The durable task in the Cloudflare example is now served over the relay:
its output carries the invocation count so a run shows the eviction and
the resumed invocation, and the README describes the expected result and
the ws:// development switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
The README and LIMITATIONS drop the "not served yet" durable limitation:
durable tasks run on the websocket the operator dials, ctx.now, sleepFor,
sleepUntil, waitFor, waitForEvent, spawnChild and spawnChildren work over
the relay, and the inline wait budget is the only eviction rule. The
README gains the durable declaration from the plan, the test operator's
invokeDurable and resume flow, and the done-frame outcomes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
Durable memo keys are computed with WebCrypto, which is a global from
Node 20. The engines field lets package managers warn on older runtimes
at install time instead of failing on the first memoized call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
…to belanger/serverless-ts-core

# Conflicts:
#	examples/serverless/cloudflare-workers/README.md
#	examples/serverless/cloudflare-workers/src/hatchet.ts
#	examples/serverless/cloudflare-workers/src/index.ts
The contract gained freshness rules the package did not enforce. Signed
POST bodies (healthcheck and trigger) are now verified in the contract's
order: the HMAC over the raw body, the body as JSON, its timestamp
(protojson int64 as a decimal string, or a number) within
REQUEST_MAX_AGE_SECONDS of the handler's clock in either direction (401,
retry false), then the endpoint id when one is configured (403). The
upgrade check uses the same either-direction window and consumes the
nonce only after the signature verified, from a bounded in-memory
NonceSet by default (4096 entries, expiring with the window, per
isolate) that the seenNonce option still replaces. The first durable
frame must name the task id and invocation the upgrade headers were
signed for; a mismatch closes the socket with 1008 before any task code
runs and sends no done frame.

The test operator sends fresh healthcheck-shaped bodies for raw requests,
accepts timestamp and endpoint id overrides on deliver, and can rewrite
the first frame; the tests cover stale and future timestamps on both
routes, both boundaries of the window, the 403 on a foreign endpoint id,
the replayed and unsigned-then-signed nonce cases, NonceSet expiry and
capacity, and the 1008 close. The lockfile picks up the merged SDK's
dependency list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
Ports the operator branch's "What the Worker verifies" section into the
package README, rewritten for the package: the POST checks and the
idempotency key, the upgrade checks and the per-isolate nonce set with
the production advice, and the first-frame identity check. The example
README points at it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
The package publishes under the alpha dist-tag with public access. The
publish script rebuilds the SDK it links, then lints, tests and builds
before publishing, so a release never ships without the checks CI runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
…e policy

The lockfile is resolved with pnpm's minimum release age at one day, so
installs that enforce that policy accept every entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
….yaml

pnpm 10 runs dependency install scripts only for allowlisted packages and,
under strict-dep-builds, fails on any package left undecided. The
workspace file allows esbuild and grpc-tools, which need their binaries,
and ignores the SDK's postinstall and protobufjs, which need nothing at
install time; it also carries the one day minimum release age. esbuild
follows tsup's range so one copy is installed, and the SDK build step
installs without strict-dep-builds since building its dist needs no
install scripts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
abelanger5 and others added 3 commits September 28, 2026 10:32
Workflows and actions travel under their declared names: the package no longer strips a namespace from action ids, workflow names or event keys, and the test operator sends plain names. The signed envelopes carry timestamp_unix_seconds, and the healthcheck parses that field for the freshness window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
The tasks are registered under their declared names, the healthcheck envelope carries timestampUnixSeconds, and the README documents a run against a local engine: the tenant entitlement, the signing secret in .dev.vars, a public https tunnel in front of wrangler dev (the API's endpoint URL check accepts https on 443 only), endpoint registration over REST, and triggering runs over REST or the SDK with tls_strategy none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
abelanger5 and others added 6 commits September 28, 2026 13:53
`@hatchet-dev/serverless/vercel` exports `vercel({ workflows })`, which returns
the `{ GET, POST }` handlers for a Next.js App Router catch-all route: POST is
the healthcheck and non-durable trigger, GET accepts the durable websocket
through `experimental_upgradeWebSocket` from `@vercel/functions` and keeps the
relay alive with `waitUntil`. The route is read from the catch-all segment so
the route file can live anywhere. `vercelServer({ workflows })` is the same
endpoint as an `http.Server` for a standalone `api/*.ts` function.

`@hatchet-dev/serverless/node` exports `nodeHandler({ workflows })`, an
`http.Server` request listener that doubles as Express middleware and carries
an `upgrade` listener backed by `ws`, and `createServer({ workflows })` with
both wired.

Both adapters read the secret from `process.env.HATCHET_SIGNING_SECRET`, treat
`ws` and `@vercel/functions` as optional peers, and advertise
`durable.supported: false` when those cannot load, so the operator never dials
an endpoint that cannot accept the socket. The edge check keeps covering only
the runtime-agnostic entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
Adds the Vercel and Next.js section (route handlers, the waitUntil choice
over after(), how an unavailable upgrade is answered, Vercel's limits, the
standalone vercelServer shape), the Node section (createServer, nodeHandler
as an Express middleware with the upgrade listener) and an Adapters table
across the three runtimes. The Vercel route context is typed the way Next.js
checks it (params as a promise), which next build enforces.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
A Next.js 15 App Router app serving echo and sleep-then-echo (the Cloudflare
example's tasks, same names and outputs) through
app/api/hatchet/[...hatchet]/route.ts with the Vercel adapter, and triggering
them from a server action and a curl route with the SDK's core client. The
README covers the local engine run, the tunnel the operator needs, endpoint
registration as GENERIC_HTTP, vc dev versus next dev for the websocket path,
maxDuration and the deploy to Vercel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
…v/serverless.

The handler takes `client` (a HatchetCore, its config, or a function of the
runtime's env returning either) and builds the SDK's core client from it on
first use, one per handler or per env object. With it, `ctx.runNoWaitChild`,
`ctx.bulkRunNoWaitChildren`, `ctx.putStream`, `ctx.cancel` and `ctx.log`
delegate to unary calls; without it they keep throwing, now worded "configure
`client` to enable this". The token comes from the platform's secret store and
is never sent by the operator.

Awaiting a child (`ctx.runChild`, `ctx.bulkRunChildren`, `result()` on a
reference made inside a task) goes over the invocation websocket: a stream
multiplexer sends stream_open/stream_message/stream_close frames with
endpoint-chosen ids, mirrors the operator's allowed procedures and its
16-stream cap, and closes every stream before the done frame or fails them
when the socket goes away. A run watcher keeps every child of a task on one
/Dispatcher/SubscribeToWorkflowRuns stream and resolves each waiter by run id;
an empty terminal event falls back to GetRunDetails. A task gets a socket only
when the catalog asks for one, so the handler takes `streams` (declarations or
action ids) and advertises them as `tasks: [{ action, streams: true }]`; the
invocation loop runs a non-durable task on the socket under a plain Context as
invocation 1. Over a POST the wait throws and names the option; inside a
durable task `ctx.runChild` throws and names `ctx.spawnChild`.

The test operator answers run subscriptions from a scripted engine
(`completeRun`, `runStream.workflowRun`), routes flagged tasks over the socket
the way the operator does, refuses durable requests on a non-durable socket and
exposes `closeStream`/`openStreams` for mid-flight tests. The Cloudflare adapter
reads HATCHET_CLIENT_TOKEN by default. README and LIMITATIONS.md describe the
rules; the package is 0.1.0-alpha.2 and needs the SDK's /core entry
(1.34.0-alpha.2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
`parent-echo` triggers `echo` through the Worker's client and awaits it with
`ctx.runChild`; `src/index.ts` lists it in `streams` and builds the client from
HATCHET_CLIENT_TOKEN, with HATCHET_CLIENT_SERVER_URL and `tls: { strategy:
'none' }` for a local engine over plain HTTP. The README covers both secrets,
the .dev.vars for a local run and what the outputs and errors look like.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gXd9VgU8FwoN3DijfprEK
# Conflicts:
#	sdks/typescript-serverless/README.md
#	sdks/typescript-serverless/package.json
#	sdks/typescript-serverless/scripts/check-edge-entry.mjs

This branch was successfully deployed

1 active deployment
Preview — 8ea0c1e5 Deployed Oct 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation github_actions Pull requests that update GitHub Actions code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant