Next.js Authoring

Waymark's first JavaScript target is Next.js. The runtime model is the same - workflows define durable control flow, actions define distributed work - but the front-end is a TypeScript compiler that lowers your authored source into the Waymark IR the Rust runtime already executes.

Status

The JavaScript SDK is in active development inside the alpha branch. Most of the design is locked in and matches the Python authoring model - the rest of this page walks through how to author against it. The example app at example-app/javascript is the running reference implementation, and it's what to crib from for real wiring once the package ships.

Actions

Actions are regular exported async functions, marked by a // use action comment immediately above the declaration:

import { db } from "@/lib/db";
import { emailClient } from "@/lib/email";

// use action
export async function fetchUsers(userIds: string[]): Promise<User[]> {
  return await db.user.findMany({
    where: { id: { in: userIds } },
  });
}

// use action
export async function sendWelcomeEmail(input: {
  to: string;
}): Promise<EmailResult> {
  return await emailClient.send({ to: input.to, subject: "Welcome" });
}

Authoring rules for the first pass:

  • The // use action comment sits directly above the exported function.
  • Actions are top-level named exports.
  • Actions are async.
  • Actions live in server-only modules.

The compiler scans for these comments before Next.js strips them, so the declaration form matters. Arrow functions and class methods are deferred to a later release; stick to named async function declarations for now.

Workflows

Workflows are classes that extend Workflow. Inside run() you write plain TypeScript control flow - if, for...of, while, try/catch, local assignments, Promise.all(...). The compiler lowers that body into Waymark IR at build time.

import { Workflow } from "@waymark/nextjs";

import { fetchUsers, sendWelcomeEmail } from "@/lib/actions/users";

export class WelcomeEmailWorkflow extends Workflow {
  async run(userIds: string[]): Promise<EmailResult[]> {
    const users = await fetchUsers(userIds);
    const activeUsers = users.filter((user) => user.active);

    return await Promise.all(
      activeUsers.map((user) =>
        this.runAction(sendWelcomeEmail({ to: user.email }), {
          retry: { attempts: 5, backoffSeconds: 30 },
          timeout: "10m",
        }),
      ),
    );
  }
}

Same shape as the Python Workflow class. run() is the durable entrypoint. this.runAction(...) is the explicit form for attaching retry or timeout metadata to a call (and is what you should reach for when the MVP rejects a plain inline call).

A few rules for the first pass:

  • A workflow is an exported class extending Workflow.
  • The durable entrypoint is async run(...).
  • Workflow inputs go on run(...), not on constructor(...).

Starting from a route

The invocation site stays normal:

import { WelcomeEmailWorkflow } from "@/lib/workflows/welcome-email";

export async function POST(request: Request): Promise<Response> {
  const { userIds } = await request.json();

  const workflow = new WelcomeEmailWorkflow();
  const result = await workflow.run(userIds);

  return Response.json({ result });
}

workflow.run(...) no longer executes the body you authored - the compiler replaced that body with code that submits the precompiled IR to the bridge and waits for completion. The authored body is compiler input, not runtime business logic.

withWaymark()

withWaymark(...) is the build-time integration point for Next.js. The project opts in by wrapping its config:

// next.config.js
const { withWaymark } = require("@waymark/nextjs");

module.exports = withWaymark({
  reactStrictMode: true,
});

What it does at build time:

  1. Registers a server-side loader for .ts / .tsx / .js modules.
  2. Parses raw source AST before Next.js strips the // use action comments.
  3. Discovers actions by // use action and workflows by class ... extends Workflow.
  4. Rewrites action modules so they self-register on import.
  5. Rewrites workflow run() bodies to embed the compiled IR and submit it to the bridge instead of executing the authored body.
  6. Maintains a generated bootstrap file at .waymark/actions-bootstrap.mjs that the worker loads at startup.
  7. Invalidates rebuilds correctly when imported action modules change.

What it explicitly does not do: start the bridge, start workers, or execute actions itself. Those are runtime concerns owned by waymark-start-workers and the bridge binary.

What feels the same as Python

  • Actions are the retryable, distributed work units.
  • Workflows are the durable control-flow layer.
  • The workflow definition is compiled once, not re-run during recovery.
  • Retry and timeout policy attach to action calls, not the workflow as a whole.
  • Parallel fan-out uses the language's native primitive. In TypeScript that means Promise.all(...); there's no Waymark-specific parallel API.

Initial restrictions

The first pass is intentionally narrow:

  • Named async function declarations only - no arrow functions, no class methods marked as actions yet.
  • Workflow inputs on run(...), not stored on the class.
  • The compiler only inspects run(...) - helper methods on the class body are ignored, so push helpers into actions (or into plain functions called from inside actions).

These restrictions exist to keep the AST surface small. Expect them to loosen as the implementation lands; track the design doc for the latest.