Files
vercel__chat/packages/chat/scripts/check-workflow-serialization-bundle.mjs
Bryan Hunter bdeb2bf1b1 fix(workflow): isolate chat serializers from node runtime (#806)
## Failure

Workflow SDK `5.0.0-beta.40` produces an invalid workflow bundle when a
Chat SDK serializable class such as `Message`, `ThreadImpl`, or
`ChannelImpl` crosses a workflow step boundary.

The Workflow compiler imports the emitted module containing each class
to register its `@workflow/serde` methods. In Chat SDK `4.37.0`, tsup
emits those classes in `dist/index.js`. The root entry also imports the
conversation-scoping implementation added in #751, which uses
`AsyncLocalStorage` from `node:async_hooks`. Serializer registration
therefore pulls Node-only code into the sandboxed workflow bundle before
any workflow or step executes.

Build warning:

```text
Serde warning for classes "ChannelImpl", "Message", "ThreadImpl":
Workflow bundle contains Node.js built-in imports: async_hooks.
These will fail at runtime in the workflow sandbox.
```

Deployed workflows then fail during module initialization:

```text
var import_async_hooks = require("async_hooks");
                         ^

ReferenceError: require is not defined
```

## Minimal reproduction

```json
{
  "dependencies": {
    "chat": "4.37.0",
    "workflow": "5.0.0-beta.40"
  }
}
```

```ts
import { Message } from "chat";

async function createMessageStep(value: string): Promise<Message> {
  "use step";

  return new Message({
    id: "message",
    threadId: "slack:C123:123.456",
    text: value,
    formatted: {
      type: "root",
      children: [
        {
          type: "paragraph",
          children: [{ type: "text", value }],
        },
      ],
    },
    raw: {},
    author: {
      userId: "U123",
      userName: "user",
      fullName: "User",
      isBot: false,
      isMe: false,
    },
    metadata: { dateSent: new Date(), edited: false },
    attachments: [],
  });
}

export async function testWorkflow(value: string): Promise<string> {
  "use workflow";

  const message = await createMessageStep(value);
  return message.text;
}
```

Running `workflow build` on `4.37.0` emits the warning; deploying the
output produces the runtime failure above.

## Fix

- Add a dedicated `chat/serialization` package entry for `Message`,
`ThreadImpl`, `ChannelImpl`, `reviver`, and their serialized DTO types.
- Make serializer code a second tsup entry and explicitly enable
splitting. The serializer-bearing classes are now emitted into a shared
chunk with no dependency on `Chat` or its Node-only conversation
context.
- Preserve the existing root exports and automatic `@workflow/serde`
behavior. Existing `import { Message } from "chat"` workflow code
remains valid.
- Add a post-build module-graph assertion that fails if any emitted
serializer registration can transitively import a Node.js builtin.
- Test against Workflow SDK `5.0.0-beta.40`, the compiler version that
exposed the invalid bundle.
- Add a minor changeset for the fixed-version Chat SDK packages,
producing the `4.38.0` release line.

After the change, the emitted serializer classes live in a sandbox-safe
shared chunk while `AsyncLocalStorage` remains in a separate Node
runtime chunk. The exact reproduction compiles successfully with `5
steps, 1 workflow` and no Serde warning.

## Control cases

The failure requires a serializable Chat class to cross a durable
boundary. These cases were already safe and remain unchanged:

- `AsyncLocalStorage` used entirely inside a `"use step"` function.
- A Chat `Message` created and consumed within one step while returning
plain data.
- Request handlers that convert Chat objects to plain workflow DTOs
before starting a workflow.
- `@vercel/sandbox` used entirely inside a step.

## Validation

- Committed beta.40 reproduction fixture: type-correct and compiled
during every Chat package build with no Node builtin / Serde warning.
- Emitted serializer module graph: no transitive Node.js builtins.
- Chat package: 1,113 tests pass.
- Chat package typecheck passes.
- Repository formatting and lint checks pass.
- Package build passes.

Full repository validation reaches the pre-existing `knip` baseline and
reports unrelated unused dependencies and unlisted binaries in examples
and adapter packages.

---------

Signed-off-by: bryan-hunter <bryan.hunter@vercel.com>
2026-08-10 09:03:49 -05:00

99 lines
2.8 KiB
JavaScript

import { spawnSync } from "node:child_process";
import { copyFile, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { isBuiltin } from "node:module";
import { basename, dirname, extname, join, resolve } from "node:path";
import { init, parse } from "es-module-lexer";
const packageDirectory = resolve(import.meta.dirname, "..");
const distDirectory = join(packageDirectory, "dist");
const serializerRegistrationPattern = /static \[WORKFLOW_SERIALIZE\d*\]/u;
const files = await readdir(distDirectory, { recursive: true });
const javascriptFiles = files
.filter((file) => extname(file) === ".js")
.map((file) => join(distDirectory, file));
const sources = new Map(
await Promise.all(
javascriptFiles.map(async (file) => [file, await readFile(file, "utf8")])
)
);
const serializerFiles = javascriptFiles.filter((file) =>
serializerRegistrationPattern.test(sources.get(file) ?? "")
);
if (serializerFiles.length === 0) {
throw new Error(
"Chat build did not emit any Workflow serializer registrations"
);
}
await init;
const visited = new Set();
const queue = [...serializerFiles];
while (queue.length > 0) {
const file = queue.pop();
if (!file || visited.has(file)) {
continue;
}
visited.add(file);
const source = sources.get(file);
if (!source) {
throw new Error(
`Missing emitted module while checking serializers: ${file}`
);
}
const [imports] = parse(source);
for (const { d: dynamicImportStart, n: specifier } of imports) {
if (dynamicImportStart !== -1 || !specifier) {
continue;
}
if (isBuiltin(specifier)) {
throw new Error(
`Workflow serializer bundle imports Node.js builtin "${specifier}" from ${basename(file)}`
);
}
if (!specifier.startsWith(".")) {
continue;
}
const importedFile = resolve(dirname(file), specifier);
if (sources.has(importedFile)) {
queue.push(importedFile);
}
}
}
const fixtureDirectory = await mkdtemp(
join(packageDirectory, ".workflow-serialization-repro-")
);
try {
await copyFile(
join(packageDirectory, "fixtures/workflow-serialization.ts"),
join(fixtureDirectory, "workflow.ts")
);
const workflowExecutable = join(
packageDirectory,
"node_modules/.bin/workflow"
);
const result = spawnSync(workflowExecutable, ["build"], {
cwd: fixtureDirectory,
encoding: "utf8",
});
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
if (result.status !== 0) {
throw new Error(`Workflow serialization reproduction failed:\n${output}`);
}
if (/async_hooks|Serde warning/u.test(output)) {
throw new Error(
`Workflow serialization reproduction emitted a Node builtin warning:\n${output}`
);
}
} finally {
await rm(fixtureDirectory, { recursive: true, force: true });
}