mirror of
https://github.com/vercel/chat.git
synced 2026-09-14 18:32:29 +08:00
bdeb2bf1b1
## 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>
99 lines
2.8 KiB
JavaScript
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 });
|
|
}
|