diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c343fe..7f89759d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - Agent and workflow WebSocket frames reject blank or whitespace-only `requestId` values, including optional agent ping IDs. - Published the Message-Driven Agents guide, Sandbox Connector API, and Daytona integration guide on the documentation site. Replace saved root-guide or raw GitHub links with [Message-Driven Agents](https://flueframework.com/docs/guide/message-driven-agents/), [Sandbox Connector API](https://flueframework.com/docs/api/sandbox-api/), and [Daytona](https://flueframework.com/docs/ecosystem/sandboxes/daytona/). - Refreshed homepage and documentation canonical URLs and social-preview metadata. +- **Cloudflare: Extend generated deployments and addressable agents.** Add an optional source-root `cloudflare.ts` module to export application-owned Durable Objects and compose non-HTTP Worker handlers. Addressable agent modules may export `cloudflare = extend({ base, wrap })` from `@flue/runtime/cloudflare` to add native Agents SDK lifecycle hooks beneath Flue-owned routing or wrap the final generated Durable Object class with integrations such as Sentry. +- **Cloudflare Sandbox exports are now explicit.** Export Cloudflare Sandbox aliases from your source-root `cloudflare.ts` module instead of relying on the removed `Sandbox`-suffix auto-wiring. ## 0.9.0 - 2026-06-02 diff --git a/apps/docs/src/content/docs/ecosystem/deploy/cloudflare.md b/apps/docs/src/content/docs/ecosystem/deploy/cloudflare.md index bda8f898..9606f29e 100644 --- a/apps/docs/src/content/docs/ecosystem/deploy/cloudflare.md +++ b/apps/docs/src/content/docs/ecosystem/deploy/cloudflare.md @@ -152,6 +152,87 @@ chat.close(); An exported `websocket` middleware can authenticate its own agent or workflow socket endpoint. Custom `.flue/app.ts` applications provide centralized authentication and mounted prefixes: for example, apply `app.use('/api/agents/*', authenticate)` and `app.use('/api/workflows/*', authenticate)` before `app.route('/api', flue())` to cover both socket surfaces before Flue forwards accepted upgrades into their owning Durable Objects. SDK clients can connect through that mount with `baseUrl: 'https://example.com/api'` and attach query-token or signed handshake context with `websocketUrl: (url) => { url.searchParams.set('token', socketToken); return url; }`. HTTP `token` and `headers` options do not automatically apply to WebSocket upgrades; browsers should use cookies or application-designed URL authentication, while Node clients requiring implementation-specific headers can provide a custom `websocket` factory. Cloudflare socket authentication is established during the handshake: query parameters and original upgrade headers are not restored into operation-time request context after Durable Object forwarding. Avoid header-mutating middleware such as CORS wrapping WebSocket upgrade routes, because WebSocket upgrade responses may have immutable headers. +### Extending an addressable Cloudflare agent + +Flue normally owns each generated agent Durable Object class. When an addressable agent needs native Cloudflare Agents SDK capabilities such as `onStart()`, `schedule()`, `scheduleEvery()`, or `queue()`, export a `cloudflare` extension descriptor from its module: + +```ts +import { createAgent } from '@flue/runtime'; +import { extend } from '@flue/runtime/cloudflare'; + +export default createAgent(() => ({ model: 'anthropic/claude-sonnet-4-6' })); + +export const cloudflare = extend({ + base: (Base) => + class extends Base { + async onStart() { + await this.scheduleEvery(60, 'heartbeat'); + } + + async heartbeat() { + this.setState({ ...this.state, lastHeartbeatAt: Date.now() }); + } + }, +}); +``` + +This is an advanced Cloudflare-only extension point. Flue applies `base` first, then defines its own Durable Object subclass while preserving the filename-derived binding and migration name. Use `base` for native SDK lifecycle hooks and additional named methods. Do not override `fetch()`, `onRequest()`, WebSocket hooks, `onFiberRecovered()`, or `alarm()`: Flue and the Agents SDK use those methods for routing, hibernating connections, interruption recovery, and alarm multiplexing. + +Use `wrap` when an integration needs to wrap the final Flue-generated Durable Object class: + +```ts +import * as Sentry from '@sentry/cloudflare'; + +export const cloudflare = extend({ + wrap: (Final) => + Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ dsn: env.SENTRY_DSN }), + Final, + ), +}); +``` + +Both `base` and `wrap` are optional. This agent-module export is distinct from the optional source-root `.flue/cloudflare.ts` deployment module below. Native SDK callbacks run as agent-instance activity: they do not receive a Flue workflow context, create workflow runs, or automatically initialize a Flue harness or session. + +### Extending the Worker + +Add an optional `.flue/cloudflare.ts` module when your deployment needs native Cloudflare capabilities outside Flue's generated classes. Named exports become top-level Worker exports, which lets the same Worker define application-owned Durable Objects: + +```ts +import { DurableObject } from 'cloudflare:workers'; + +export class SalesforceAuthCache extends DurableObject { + async refreshIfNeeded() { + return await this.ctx.storage.get('token'); + } +} +``` + +Declare the corresponding binding and migration in your project-root `wrangler.jsonc`: + +```jsonc +{ + "durable_objects": { + "bindings": [{ "name": "SALESFORCE_AUTH_CACHE", "class_name": "SalesforceAuthCache" }], + }, + "migrations": [{ "tag": "v2", "new_sqlite_classes": ["SalesforceAuthCache"] }], +} +``` + +Your agents and workflows receive the namespace through `env.SALESFORCE_AUTH_CACHE`. Keep bindings, containers, and ordered migration history in Wrangler configuration; `cloudflare.ts` provides the Worker code exports but does not infer deployment topology. + +An optional default export adds non-HTTP Worker handlers: + +```ts +export default { + async scheduled(_controller, env) { + await env.SALESFORCE_AUTH_CACHE.getByName('default').refreshIfNeeded(); + }, +}; +``` + +Use `.flue/app.ts` for custom HTTP routes and middleware. `cloudflare.ts` must not export a default `fetch` handler because Flue keeps HTTP composition in `app.ts`. + ## Subagents Subagents define named delegates for detached task sessions: @@ -248,16 +329,23 @@ If you'd rather connect to an external provider — e.g. Daytona — instead of ### Setup -You own the container config. That means three things: +You own the container config. That means four things: 1. Install `@cloudflare/sandbox`: `npm install @cloudflare/sandbox`. -2. Declare the Durable Object binding, migration, and container image in your `wrangler.jsonc` at the project root. -3. Commit a `Dockerfile` at the path your `containers[].image` points to. +2. Export the Sandbox class from `.flue/cloudflare.ts`. +3. Declare the Durable Object binding, migration, and container image in your `wrangler.jsonc` at the project root. +4. Commit a `Dockerfile` at the path your `containers[].image` points to. -Flue automates one piece: **any DO binding whose `class_name` ends with `Sandbox` is automatically wired up as `@cloudflare/sandbox`'s `Sandbox` class in the generated Worker bundle.** Pick any name you want (`Sandbox`, `PyBoxSandbox`, `SupportSandbox`, …) and Flue handles the re-export. Append its migration to the same top-level history you use for generated Flue classes; do not replace migrations that have already been deployed. +Append the Sandbox migration to the same top-level history you use for generated Flue classes; do not replace migrations that have already been deployed. ### Example +`.flue/cloudflare.ts`: + +```ts +export { Sandbox } from '@cloudflare/sandbox'; +``` + `wrangler.jsonc` (at the project root, alongside `package.json`): ```jsonc @@ -301,7 +389,13 @@ export default createAgent(({ id, env }) => ({ ### Multiple sandboxes -Different agents can use different container images. Declare a separate binding for each (each `class_name` must end with `Sandbox`), and give each its own container entry: +Different agents can use different container images. Export a separate alias for each Sandbox class, then declare each binding and container entry: + +```ts +// .flue/cloudflare.ts +export { Sandbox as PyBoxSandbox } from '@cloudflare/sandbox'; +export { Sandbox as NodeSandbox } from '@cloudflare/sandbox'; +``` ```jsonc { @@ -331,7 +425,9 @@ When your agent runs in a container, it may need to call external APIs — GitHu Cloudflare Sandboxes solve this with [outbound Workers](https://blog.cloudflare.com/sandbox-auth/) — a programmable egress proxy that intercepts outgoing HTTP/HTTPS requests from the container. Secrets are injected at the proxy layer, so the container never sees them. This is configured on the Cloudflare Sandbox class, outside of your Flue agent code: ```typescript -class MySandbox extends Sandbox { +import { Sandbox } from '@cloudflare/sandbox'; + +export class MySandbox extends Sandbox { static outboundByHost = { 'api.github.com': (request, env, ctx) => { const headers = new Headers(request.headers); diff --git a/apps/docs/src/content/docs/ecosystem/sandboxes/cloudflare.md b/apps/docs/src/content/docs/ecosystem/sandboxes/cloudflare.md index 528ada50..ec383907 100644 --- a/apps/docs/src/content/docs/ecosystem/sandboxes/cloudflare.md +++ b/apps/docs/src/content/docs/ecosystem/sandboxes/cloudflare.md @@ -7,7 +7,14 @@ Cloudflare Sandbox uses `@cloudflare/sandbox` to provide a container-backed Linu ## Use the Cloudflare target -Cloudflare Sandbox requires a Worker deployment, Durable Object/container configuration, and a container image. Add the dependency to a Cloudflare-targeted project, declare the sandbox binding in Wrangler configuration, and pass the RPC stub returned by `getSandbox(...)` to an agent: +Cloudflare Sandbox requires a Worker deployment, Durable Object/container configuration, and a container image. Add the dependency to a Cloudflare-targeted project and export its Durable Object class from your Cloudflare deployment module: + +```ts +// /cloudflare.ts +export { Sandbox } from '@cloudflare/sandbox'; +``` + +Declare the sandbox binding in Wrangler configuration, then pass the RPC stub returned by `getSandbox(...)` to an agent: ```ts import { getSandbox } from '@cloudflare/sandbox'; diff --git a/apps/docs/src/content/docs/guide/develop-and-build.md b/apps/docs/src/content/docs/guide/develop-and-build.md index 5e711eea..3138db11 100644 --- a/apps/docs/src/content/docs/guide/develop-and-build.md +++ b/apps/docs/src/content/docs/guide/develop-and-build.md @@ -10,7 +10,7 @@ This guide covers that lifecycle. For source files and discovery conventions, se ## Develop -`flue dev` is the local development server for a Flue application. It builds the discovered agents, workflows, and optional `app.ts`, serves the application locally, and rebuilds as source files change. +`flue dev` is the local development server for a Flue application. It builds the discovered agents, workflows, optional `app.ts`, and optional Cloudflare-only `cloudflare.ts`, serves the application locally, and rebuilds as source files change. After selecting your normal runtime target in `flue.config.ts`, start the development server: @@ -54,7 +54,7 @@ pnpm exec flue build The build uses that configured target, or a one-time `--target` override, to produce deployable output in `dist/` by default. See [Configuration](/docs/reference/configuration/) to change the output directory. -A build packages the application for its runtime environment. It does not choose a model, add provider credentials, expose additional routes, or configure platform-owned bindings. Keep those concerns in your authored application modules, secrets configuration, and deployment-platform configuration. +A build packages the application for its runtime environment. It does not choose a model, add provider credentials, expose additional routes, or configure platform-owned bindings. Keep those concerns in your authored application modules, secrets configuration, and deployment-platform configuration. Cloudflare applications may add platform-specific Worker exports and non-HTTP handlers in `cloudflare.ts`; see [Deploy on Cloudflare](/docs/ecosystem/deploy/cloudflare/#extending-the-worker). ## Deploy diff --git a/apps/docs/src/content/docs/guide/project-layout.md b/apps/docs/src/content/docs/guide/project-layout.md index ae104b5c..a745734e 100644 --- a/apps/docs/src/content/docs/guide/project-layout.md +++ b/apps/docs/src/content/docs/guide/project-layout.md @@ -4,7 +4,7 @@ description: Understand the source files and generated output in a Flue project. lastReviewedAt: 2026-05-29 --- -Flue discovers application entrypoints from your project's source directory. Use `src/` for new projects, with `app.ts`, `agents/`, and `workflows/` defining the application surfaces Flue builds. +Flue discovers application entrypoints from your project's source directory. Use `src/` for new projects, with `app.ts`, `cloudflare.ts`, `agents/`, and `workflows/` defining the application surfaces Flue builds. ## Example project layout @@ -14,6 +14,7 @@ my-project/ ├─ flue.config.ts ├─ src/ │ ├─ app.ts +│ ├─ cloudflare.ts │ ├─ agents/ │ │ └─ support-assistant.ts │ └─ workflows/ @@ -27,8 +28,9 @@ Organize supporting application code however you prefer inside `src/`. The files | Path | Purpose | Learn more | | ------------ | ------------------------------------------------------------------------------------- | -------------------------------------- | -| `app.ts` | Optional entrypoint for composing Flue with your application's routes and middleware. | [Routing](/docs/guide/routing/) | -| `agents/` | Addressable agents that can receive continuing interactions over time. | [Agents](/docs/guide/building-agents/) | +| `app.ts` | Optional entrypoint for composing Flue with your application's routes and middleware. | [Routing](/docs/guide/routing/) | +| `cloudflare.ts` | Optional Cloudflare-only module for Worker exports and non-HTTP handlers. | [Cloudflare](/docs/ecosystem/deploy/cloudflare/#extending-the-worker) | +| `agents/` | Addressable agents that can receive continuing interactions over time. | [Agents](/docs/guide/building-agents/) | | `workflows/` | Finite operations that receive input and return a result. | [Workflows](/docs/guide/workflows/) | ### `app.ts` @@ -37,6 +39,12 @@ Organize supporting application code however you prefer inside `src/`. The files For more information, see [Routing](/docs/guide/routing/). +### `cloudflare.ts` + +`cloudflare.ts` is an optional Cloudflare-only deployment module. Its named exports become top-level Worker exports, and its optional default export adds non-HTTP Worker handlers. Use it for same-Worker Durable Object classes, explicit Cloudflare Sandbox aliases, queue consumers, scheduled handlers, and other Cloudflare-native additions. Custom HTTP handling remains in `app.ts`. + +For more information, see [Deploy on Cloudflare](/docs/ecosystem/deploy/cloudflare/#extending-the-worker). + ### `agents/` The `agents/` directory contains agents that Flue can address by name. Each immediate file defines one discovered agent, and its filename becomes the agent name: `src/agents/support-assistant.ts` is discovered as `support-assistant`. @@ -61,7 +69,7 @@ For more information, see [Workflows](/docs/guide/workflows/). 2. `src/` **(Recommended)** — The recommended layout for new projects. 3. The project root — A compact layout for small dedicated projects. -The first matching directory wins. Flue does not merge layouts: when `.flue/` exists, it does not discover agents, workflows, or `app.ts` from `src/` or the project root. Authored modules may still import ordinary supporting code from elsewhere in the project. +The first matching directory wins. Flue does not merge layouts: when `.flue/` exists, it does not discover agents, workflows, `app.ts`, or `cloudflare.ts` from `src/` or the project root. Authored modules may still import ordinary supporting code from elsewhere in the project. The source directory is always discovered relative to your project root. To configure the project root, see [Configuration](/docs/reference/configuration/). diff --git a/connectors/sandbox--cloudflare.md b/connectors/sandbox--cloudflare.md index bde1d36a..ec799f79 100644 --- a/connectors/sandbox--cloudflare.md +++ b/connectors/sandbox--cloudflare.md @@ -21,9 +21,10 @@ Node.js process. Because of that, Flue treats Cloudflare Sandbox as a first-class **build target**, not a drop-in connector file. If the user is already on `--target cloudflare`: there is no connector to -install. Flue's runtime package already provides the wiring; you just declare the -binding in `wrangler.jsonc` and call `getSandbox(env.Sandbox, id)` in the -agent. Skip to ["Path A"](#path-a-already-on---target-cloudflare) below. +install. Export the Sandbox class from the selected Flue source root's +`cloudflare.ts`, declare the binding in `wrangler.jsonc`, and call +`getSandbox(env.Sandbox, id)` in the agent. Skip to +["Path A"](#path-a-already-on---target-cloudflare) below. If the user is on `--target node` (or hasn't picked yet): adding Cloudflare Sandbox means **migrating the entire project to deploy on Cloudflare @@ -77,10 +78,17 @@ The short version, for your reference: (Use the user's package manager — `pnpm add`, `yarn add`, etc.) -2. Add a Durable Object binding for the sandbox to the user's - `wrangler.jsonc` (at the project root). **The `class_name` must end - with `Sandbox`** — Flue's build step auto-wires any DO whose class name - ends in `Sandbox` to `@cloudflare/sandbox`'s `Sandbox` class: +2. Export the Sandbox class from the user's Cloudflare deployment module. + Put `cloudflare.ts` in the selected Flue source root: `.flue/cloudflare.ts` + when `.flue/` exists, otherwise `src/cloudflare.ts` when `src/` exists, + otherwise `/cloudflare.ts`: + + ```ts + export { Sandbox } from '@cloudflare/sandbox'; + ``` + +3. Add a Durable Object binding for the sandbox to the user's + `wrangler.jsonc` at the project root: ```jsonc { @@ -96,7 +104,7 @@ The short version, for your reference: uniquely tagged entry. Do not replace deployed agent, workflow, or `FlueRegistry` migrations. -3. Add a `Dockerfile` at the project root pinned to the matching +4. Add a `Dockerfile` at the project root pinned to the matching `@cloudflare/sandbox` version: ```dockerfile @@ -107,7 +115,7 @@ The short version, for your reference: 1 — Cloudflare publishes the base image with the same version tag as the npm package.) -4. Use it in an agent. The binding name from `wrangler.jsonc` (`Sandbox` +5. Use it in an agent. The binding name from `wrangler.jsonc` (`Sandbox` above) is the key on `env`: ```ts @@ -131,7 +139,7 @@ The short version, for your reference: because Flue's SDK detects and adapts the `@cloudflare/sandbox` shape internally on the Cloudflare target. -5. Tell the user to put local variables in `.dev.vars` or `.env` and run +6. Tell the user to put local variables in `.dev.vars` or `.env` and run `flue dev --target cloudflare`, then `flue build --target cloudflare && wrangler deploy --secrets-file .env` to deploy. No new env vars are required just for the sandbox itself; auth is the user's normal Cloudflare account auth diff --git a/examples/assistant/src/cloudflare.ts b/examples/assistant/src/cloudflare.ts new file mode 100644 index 00000000..d8ba3dc8 --- /dev/null +++ b/examples/assistant/src/cloudflare.ts @@ -0,0 +1 @@ +export { Sandbox } from '@cloudflare/sandbox'; diff --git a/examples/assistant/wrangler.jsonc b/examples/assistant/wrangler.jsonc index 7b3699a3..53665a90 100644 --- a/examples/assistant/wrangler.jsonc +++ b/examples/assistant/wrangler.jsonc @@ -2,10 +2,7 @@ // User-owned wrangler config for this Flue app. Flue reads this file, // merges its own contributions into an ignored Vite input config, and the // official Cloudflare Vite integration emits the deployable Worker output. - // - // The DO binding whose class_name ends with "Sandbox" triggers Flue's - // automatic re-export of the Sandbox class from @cloudflare/sandbox in - // the generated Worker bundle. + // src/cloudflare.ts explicitly exports the Sandbox class from @cloudflare/sandbox. "$schema": "https://workers.cloudflare.com/schema/wrangler.json", "name": "assistant", "compatibility_date": "2026-04-01", diff --git a/knip.json b/knip.json index 3eaf5f6b..73224097 100644 --- a/knip.json +++ b/knip.json @@ -1,22 +1,22 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", "ignore": ["packages/runtime/test-legacy/**"], - "ignoreDependencies": ["@cloudflare/sandbox", "@flue/cli", "agents", "wrangler"], + "ignoreDependencies": ["@flue/cli", "agents", "wrangler"], "ignoreIssues": { "packages/runtime/src/runtime/schemas.ts": ["duplicates"] }, "workspaces": { ".": { - "entry": [".flue/{app,agents/*,workflows/*}.{ts,mts,js,mjs}"], + "entry": [".flue/{app,cloudflare,agents/*,workflows/*}.{ts,mts,js,mjs}"], "project": [".flue/**/*.{ts,mts,js,mjs}"], "ignoreDependencies": ["bgproc", "just-bash"] }, "examples/*": { - "entry": ["{src/{app,agents/*,workflows/*},flue.config}.{ts,mts,js,mjs}"], + "entry": ["{src/{app,cloudflare,agents/*,workflows/*},flue.config}.{ts,mts,js,mjs}"], "project": ["src/**/*.{ts,mts,js,mjs}"] }, "examples/node-websocket": { - "entry": ["{src/{app,agents/*,workflows/*},flue.config}.{ts,mts,js,mjs}"], + "entry": ["{src/{app,cloudflare,agents/*,workflows/*},flue.config}.{ts,mts,js,mjs}"], "project": ["src/**/*.{ts,mts,js,mjs}"], "ignoreDependencies": ["@flue/sdk"] }, diff --git a/packages/cli/src/lib/build-plugin-cloudflare.ts b/packages/cli/src/lib/build-plugin-cloudflare.ts index 46f35d39..1b909183 100644 --- a/packages/cli/src/lib/build-plugin-cloudflare.ts +++ b/packages/cli/src/lib/build-plugin-cloudflare.ts @@ -1,8 +1,6 @@ /** Cloudflare build plugin. Produces a Worker + DO entry point for workflow runs and agent interactions. */ import * as path from 'node:path'; import { - assertSandboxPackageInstalled, - detectSandboxBindings, type FlueAdditions, mergeFlueAdditions, readUserWranglerConfig, @@ -16,32 +14,11 @@ export class CloudflarePlugin implements BuildPlugin { bundle: BuildPlugin['bundle'] = 'vite-cloudflare'; entryFilename = '_entry.ts'; - /** - * Per-build cache of the user's wrangler config. Both `generateEntryPoint` - * and `additionalOutputs` need it (for sandbox detection + the merge), and - * a fresh `CloudflarePlugin` instance is constructed for each build (see - * `resolvePlugin` in build.ts), so the cache is implicitly scoped to a - * single build. - */ - private userConfigCache: Awaited> | undefined; - - /** - * Read the user's wrangler config from `root`. The user's config always - * lives at the project root, regardless of where the build artifacts get - * written via `output`. We generate an internal merged Vite input config - * without modifying the user's source configuration. - */ - private async getUserConfig(root: string) { - if (!this.userConfigCache) { - this.userConfigCache = await readUserWranglerConfig(root); - } - return this.userConfigCache; - } - async generateEntryPoint(ctx: BuildContext): Promise { - const { agents, appEntry, workflows } = ctx; + const { agents, appEntry, cloudflareEntry, workflows } = ctx; const runtimeVersion = JSON.stringify(ctx.runtimeVersion); validateCloudflareAgentNames(ctx); + validateCloudflareExportNames(ctx); const agentImports = agents .map((a, index) => { @@ -69,7 +46,8 @@ export class CloudflarePlugin implements BuildPlugin { const agentClasses = agents .map( - (agent) => `export class ${agentClassName(agent.name)} extends Agent { + (agent, index) => `const agentExtension${index} = resolveCloudflareAgentExtension(agentModules[${JSON.stringify(agent.name)}], ${JSON.stringify(agent.name)}); +const GeneratedAgent${index} = class ${agentClassName(agent.name)} extends agentExtension${index}.base(Agent) { async onRequest(request) { return dispatchAgent(request, this, ${JSON.stringify(agent.name)}, directHandlers[${JSON.stringify(agent.name)}]); } @@ -111,13 +89,15 @@ export class CloudflarePlugin implements BuildPlugin { return super.onFiberRecovered(ctx); } } -}`, +}; +const WrappedAgent${index} = agentExtension${index}.wrap(GeneratedAgent${index}); +export { WrappedAgent${index} as ${agentClassName(agent.name)} };`, ) .join('\n\n'); const workflowClasses = workflows .map( - (workflow) => `export class ${workflowClassName(workflow.name)} extends Agent { + (workflow, index) => `const GeneratedWorkflow${index} = class ${workflowClassName(workflow.name)} extends Agent { async onRequest(request) { return dispatchWorkflow(request, this, ${JSON.stringify(workflow.name)}); } @@ -156,7 +136,8 @@ export class CloudflarePlugin implements BuildPlugin { return super.onFiberRecovered(ctx); } } -}`, +}; +export { GeneratedWorkflow${index} as ${workflowClassName(workflow.name)} };`, ) .join('\n\n'); @@ -173,13 +154,20 @@ export class CloudflarePlugin implements BuildPlugin { ) .join('\n'); - const { effectiveConfig } = await this.getUserConfig(ctx.root); - const sandboxClassNames = detectSandboxBindings(effectiveConfig); - const sandboxReExports = sandboxClassNames - .map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`) - .join('\n'); - const userAppImport = appEntry ? `import userApp from '${appEntry.replace(/\\/g, '/')}';` : ''; + const userCloudflareImport = cloudflareEntry + ? `import * as userCloudflareModule from '${cloudflareEntry.replace(/\\/g, '/')}';` + : ''; + const userCloudflareReExport = cloudflareEntry + ? `export * from '${cloudflareEntry.replace(/\\/g, '/')}';` + : ''; + const userCloudflareValue = cloudflareEntry ? 'userCloudflareModule' : '{}'; + const reservedCloudflareExportNames = [ + ...agents.map((agent) => agentClassName(agent.name)), + ...workflows.map((workflow) => workflowClassName(workflow.name)), + 'FlueRegistry', + ]; + const packagedSkillsImport = `import { getPackagedSkills } from 'virtual:flue/packaged-skills';`; const packagedSkillsValue = 'getPackagedSkills()'; const builtModuleNormalizationSource = generateBuiltModuleNormalizationSource(); @@ -222,12 +210,15 @@ import { connectCloudflareWorkflowWebSocket, messageCloudflareAgentWebSocket, messageCloudflareWorkflowWebSocket, + resolveCloudflareAgentExtension, } from '@flue/runtime/cloudflare'; import { registerApiProvider, registerProvider } from '@flue/runtime'; ${agentImports} ${workflowImports} ${userAppImport} +${userCloudflareImport} +${userCloudflareReExport} // ─── Internal provider registrations ──────────────────────────────────────── // User \`app.ts\` imports are hoisted above this body, so a user-supplied @@ -267,6 +258,22 @@ const workflowClassNames = { ${workflowClassMapEntries} }; +const userCloudflare = ${userCloudflareValue}; +const reservedCloudflareExportNames = new Set(${JSON.stringify(reservedCloudflareExportNames)}); +for (const name of Object.keys(userCloudflare)) { + if (name === 'default') continue; + if (reservedCloudflareExportNames.has(name)) { + throw new Error('[flue] cloudflare.ts export "' + name + '" conflicts with a Flue-generated Worker export. Rename the authored export.'); + } +} +const cloudflareHandlers = 'default' in userCloudflare ? userCloudflare.default : {}; +if (typeof cloudflareHandlers !== 'object' || cloudflareHandlers === null || Array.isArray(cloudflareHandlers)) { + throw new Error('[flue] cloudflare.ts default export must be an object containing non-HTTP Worker handlers.'); +} +if ('fetch' in cloudflareHandlers) { + throw new Error('[flue] cloudflare.ts default export must not define fetch. Use app.ts for custom HTTP handling.'); +} + // ─── Sandbox Environments ─────────────────────────────────────────────────── /** @@ -768,14 +775,6 @@ ${workflowClasses} export { FlueRegistry }; -// ─── User-declared Sandbox re-exports ────────────────────────────────────── -// One line per DO binding in the user's wrangler.jsonc whose class_name -// ends with "Sandbox". Flue aliases the single \`Sandbox\` class shipped by -// \`@cloudflare/sandbox\` so each user-chosen class_name resolves at the -// bundle's top level. The binding + container image configuration is owned -// by the user's wrangler.jsonc. -${sandboxReExports} - // ─── Runtime seed ─────────────────────────────────────────────────────────── configureFlueRuntime({ @@ -832,6 +831,7 @@ const app = createDefaultFlueApp();` } export default { + ...cloudflareHandlers, fetch(request, env, ctx) { return app.fetch(request, env, ctx); }, @@ -872,7 +872,7 @@ export default { config: userConfig, effectiveConfig, path: userConfigPath, - } = await this.getUserConfig(ctx.root); + } = await readUserWranglerConfig(ctx.root); if (userConfigPath) { console.log(`[flue] Merging with user wrangler config: ${userConfigPath}`); } @@ -888,19 +888,6 @@ export default { doBindings: flueBindings, }; - // Detect user-declared Sandbox bindings and verify the @cloudflare/sandbox - // package is available before the Vite build tries to resolve it. Log each - // binding we've auto-wired so users can see what Flue did on their behalf. - const sandboxClassNames = detectSandboxBindings(effectiveConfig); - if (sandboxClassNames.length > 0) { - assertSandboxPackageInstalled(sandboxClassNames, ctx.root); - for (const className of sandboxClassNames) { - console.log( - `[flue] Auto-wiring DO binding "${className}" to @cloudflare/sandbox's Sandbox class.`, - ); - } - } - const merged = mergeFlueAdditions(userConfig, additions); // Always include the wrangler JSON schema reference if absent so the @@ -931,6 +918,31 @@ function workflowVarName(name: string, index: number): string { const CLOUDFLARE_AGENT_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; +function validateCloudflareExportNames(ctx: BuildContext): void { + const entries = [ + ...ctx.agents.map((agent) => ({ name: agentClassName(agent.name), source: `agent "${agent.name}"` })), + ...ctx.workflows.map((workflow) => ({ + name: workflowClassName(workflow.name), + source: `workflow "${workflow.name}"`, + })), + { name: 'FlueRegistry', source: 'Flue registry' }, + ]; + const sourcesByName = new Map(); + for (const entry of entries) { + const sources = sourcesByName.get(entry.name) ?? []; + sources.push(entry.source); + sourcesByName.set(entry.name, sources); + } + const conflicts = [...sourcesByName] + .filter(([, sources]) => sources.length > 1) + .map(([name, sources]) => `"${name}" (${sources.join(', ')})`) + .join(', '); + if (!conflicts) return; + throw new Error( + `[flue] Cloudflare target generated conflicting Worker export name(s): ${conflicts}. Rename the conflicting agent or workflow file.`, + ); +} + function validateCloudflareAgentNames(ctx: BuildContext): void { // Agents and workflows both materialize as per-definition Durable Object // classes and bindings, so both need to round-trip through the kebab-case diff --git a/packages/cli/src/lib/build.ts b/packages/cli/src/lib/build.ts index f774d247..958d5be5 100644 --- a/packages/cli/src/lib/build.ts +++ b/packages/cli/src/lib/build.ts @@ -53,7 +53,8 @@ async function buildApplication(options: BuildOptions): Promise { const agents = discoverAgents(sourceRoot); const workflows = discoverWorkflows(sourceRoot); - const appEntry = discoverAppEntry(sourceRoot); + const appEntry = discoverOptionalEntry(sourceRoot, 'app'); + const cloudflareEntry = discoverOptionalEntry(sourceRoot, 'cloudflare'); if (agents.length === 0 && workflows.length === 0) { throw new Error( @@ -66,6 +67,11 @@ async function buildApplication(options: BuildOptions): Promise { if (appEntry) { console.log(`[flue] Custom app entry: ${path.relative(root, appEntry) || appEntry}`); } + if (cloudflareEntry && plugin.name === 'cloudflare') { + console.log( + `[flue] Custom Cloudflare entry: ${path.relative(root, cloudflareEntry) || cloudflareEntry}`, + ); + } if (agents.length > 0) { console.log(`[flue] Found ${agents.length} agent(s): ${agents.map((a) => a.name).join(', ')}`); @@ -89,6 +95,7 @@ async function buildApplication(options: BuildOptions): Promise { root, output, appEntry, + cloudflareEntry, runtimeVersion: readRuntimeVersion(root), options, }; @@ -143,11 +150,11 @@ async function buildApplication(options: BuildOptions): Promise { } const inputDir = cloudflareViteInputDir(root); const entryPath = path.join(inputDir, plugin.entryFilename); + const inputs = await plugin.additionalOutputs(ctx); let generatedChanged = !fs.existsSync(entryPath) || fs.readFileSync(entryPath, 'utf-8') !== serverCode; fs.mkdirSync(inputDir, { recursive: true }); if (generatedChanged) fs.writeFileSync(entryPath, serverCode, 'utf-8'); - const inputs = await plugin.additionalOutputs(ctx); for (const [filename, content] of Object.entries(inputs)) { const filePath = filename === 'wrangler.jsonc' @@ -300,18 +307,9 @@ function discoverWorkflows(sourceRoot: string): WorkflowInfo[] { })); } -/** - * Discover an optional `app.{ts,mts,js,mjs}` entry alongside `agents/`. - * Returns the absolute path to the first match found, or - * undefined when no app entry is present. - * - * Extension priority matches {@link discoverAgents}: `.ts` > `.mts` - * > `.js` > `.mjs`. Source-files-only — we don't probe inside the - * `agents/` subdir. - */ -function discoverAppEntry(sourceRoot: string): string | undefined { +function discoverOptionalEntry(sourceRoot: string, basename: string): string | undefined { for (const ext of ['ts', 'mts', 'js', 'mjs']) { - const candidate = path.join(sourceRoot, `app.${ext}`); + const candidate = path.join(sourceRoot, `${basename}.${ext}`); if (fs.existsSync(candidate)) return candidate; } return undefined; diff --git a/packages/cli/src/lib/cloudflare-wrangler-merge.ts b/packages/cli/src/lib/cloudflare-wrangler-merge.ts index 5454b91f..1f932cd9 100644 --- a/packages/cli/src/lib/cloudflare-wrangler-merge.ts +++ b/packages/cli/src/lib/cloudflare-wrangler-merge.ts @@ -216,7 +216,6 @@ export function mergeFlueAdditions( .filter((n): n is string => typeof n === 'string'), ); for (const binding of additions.doBindings) { - if (binding.name !== 'FLUE_REGISTRY') continue; if (!existingBindingNames.has(binding.name)) continue; const existing = existingBindings.find((b): b is Record => { if (typeof b !== 'object' || b === null) return false; @@ -255,92 +254,3 @@ export function mergeFlueAdditions( return merged; } - -// ─── Sandbox binding detection ────────────────────────────────────────────── - -/** - * Return the list of `class_name`s declared in the user's wrangler - * `durable_objects.bindings` that end with the literal suffix `Sandbox` - * (case-sensitive). - * - * This is Flue's convention for wiring `@cloudflare/sandbox`: any DO binding - * whose class name ends with `Sandbox` triggers an automatic re-export in the - * generated Worker entry: - * - * export { Sandbox as } from '@cloudflare/sandbox'; - * - * The alias lets users pick arbitrary class names (e.g. `PyBoxSandbox`, - * `SupportSandbox`) while still pointing at the single class shipped by the - * `@cloudflare/sandbox` package. Each distinct `class_name` can be paired with - * a different container image in the user's `containers[]` config. - * - * The match is intentionally a suffix (not substring) so that user-defined - * classes whose names merely contain "Sandbox" mid-word — e.g. `MySandboxV2`, - * `MySandboxedAgent`, `LegacySandboxedThing` — are not silently overridden - * by the `@cloudflare/sandbox` re-export. Note that classes whose names - * still end in `Sandbox` (e.g. `MockSandbox`, `NotASandbox`) will match; - * to opt out, rename the class to not end in `Sandbox`. - * - * Returns unique, sorted class names. Non-object bindings or bindings without - * a string `class_name` are ignored. - */ -export function detectSandboxBindings(userConfig: Record): string[] { - const doObj = userConfig.durable_objects; - if (typeof doObj !== 'object' || doObj === null) return []; - const bindings = (doObj as Record).bindings; - if (!Array.isArray(bindings)) return []; - - const found = new Set(); - for (const entry of bindings) { - if (typeof entry !== 'object' || entry === null) continue; - const className = (entry as Record).class_name; - if (typeof className !== 'string') continue; - if (className.endsWith('Sandbox')) found.add(className); - } - return Array.from(found).sort(); -} - -// ─── @cloudflare/sandbox install check ────────────────────────────────────── - -/** - * When the user has declared one or more `Sandbox`-named DO bindings, verify - * that `@cloudflare/sandbox` is declared in the nearest package.json. Surfaces - * a friendly, actionable error at build time rather than letting the bundler - * emit a confusing module-resolution failure. - * - * The check is lenient: if no package.json can be located or parsed, we skip - * silently and let the bundler's own error path take over. This avoids false - * positives in unusual project layouts. - */ -export function assertSandboxPackageInstalled(sandboxClassNames: string[], root: string): void { - if (sandboxClassNames.length === 0) return; - - let current = root; - while (current !== path.dirname(current)) { - const pkgPath = path.join(current, 'package.json'); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); - const allDeps = { - ...(pkg.dependencies ?? {}), - ...(pkg.devDependencies ?? {}), - ...(pkg.peerDependencies ?? {}), - ...(pkg.optionalDependencies ?? {}), - }; - if ('@cloudflare/sandbox' in allDeps) return; - // Found a package.json but no dep — keep walking in case this - // is a nested package and the dep is declared higher up (e.g. - // pnpm workspace root). - } catch { - return; // unparseable package.json — give up, let the bundler report it - } - } - current = path.dirname(current); - } - - throw new Error( - `[flue] Your wrangler config declares DO binding(s) whose class_name ends with "Sandbox" ` + - `(${sandboxClassNames.join(', ')}), but @cloudflare/sandbox is not in your package.json. ` + - `Install it: \`npm install @cloudflare/sandbox\`.`, - ); -} diff --git a/packages/cli/src/lib/dev.ts b/packages/cli/src/lib/dev.ts index baa7c2f4..45464f4a 100644 --- a/packages/cli/src/lib/dev.ts +++ b/packages/cli/src/lib/dev.ts @@ -594,7 +594,7 @@ function isSourceStructurePath(root: string, sourceRoot: string, relPath: string : relPath; if (sourceRelative === null) return false; if (sourceRelative.startsWith('agents/') || sourceRelative.startsWith('workflows/')) return true; - return /^app\.(?:ts|mts|js|mjs)$/.test(sourceRelative); + return /^(?:app|cloudflare)\.(?:ts|mts|js|mjs)$/.test(sourceRelative); } function pickExampleAgentName(sourceRoot: string): string | null { diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index 74faea0e..a4b9c8f4 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -46,6 +46,7 @@ export interface BuildContext { * `app.ts` > `app.mts` > `app.js` > `app.mjs`. */ appEntry?: string; + cloudflareEntry?: string; /** Version of @flue/runtime resolved for this build. */ runtimeVersion: string; options: BuildOptions; diff --git a/packages/runtime/src/cloudflare/agent-extension.ts b/packages/runtime/src/cloudflare/agent-extension.ts new file mode 100644 index 00000000..5e7573c2 --- /dev/null +++ b/packages/runtime/src/cloudflare/agent-extension.ts @@ -0,0 +1,123 @@ +const CLOUDFLARE_AGENT_EXTENSION = Symbol.for('@flue/runtime/cloudflare-agent-extension'); + +export type CloudflareAgentClass = new (...args: any[]) => any; + +export interface CloudflareAgentExtension { + base?: (Base: CloudflareAgentClass) => CloudflareAgentClass; + wrap?: (Final: CloudflareAgentClass) => CloudflareAgentClass; +} + +interface BrandedCloudflareAgentExtension extends CloudflareAgentExtension { + [CLOUDFLARE_AGENT_EXTENSION]: true; +} + +export interface ResolvedCloudflareAgentExtension { + base(Base: CloudflareAgentClass): CloudflareAgentClass; + wrap(Final: CloudflareAgentClass): CloudflareAgentClass; +} + +export function extend(extension: CloudflareAgentExtension): CloudflareAgentExtension { + if (typeof extension !== 'object' || extension === null || Array.isArray(extension)) { + throw new Error('[flue] extend() expects an object containing optional base and wrap callbacks.'); + } + const unknownKeys = Object.keys(extension).filter((key) => key !== 'base' && key !== 'wrap'); + if (unknownKeys.length > 0) { + throw new Error(`[flue] extend() received unknown option(s): ${unknownKeys.join(', ')}.`); + } + const branded: BrandedCloudflareAgentExtension = { + ...extension, + [CLOUDFLARE_AGENT_EXTENSION]: true, + }; + return branded; +} + +export function resolveCloudflareAgentExtension( + mod: Record, + name: string, +): ResolvedCloudflareAgentExtension { + if (mod.CloudflareAgent !== undefined) { + throw new Error( + `[flue] Agent "${name}" CloudflareAgent export is no longer supported. Export cloudflare = extend({ base, wrap }) instead.`, + ); + } + const extension = mod.cloudflare; + if (extension === undefined) return { base: identity, wrap: identity }; + if (!isCloudflareAgentExtension(extension)) { + throw new Error( + `[flue] Agent "${name}" cloudflare export must be created with extend({ base, wrap }) from "@flue/runtime/cloudflare".`, + ); + } + const base = extension.base === undefined ? identity : extension.base; + const wrap = extension.wrap === undefined ? identity : extension.wrap; + if (typeof base !== 'function') { + throw new Error(`[flue] Agent "${name}" cloudflare.base must be a function.`); + } + if (typeof wrap !== 'function') { + throw new Error(`[flue] Agent "${name}" cloudflare.wrap must be a function.`); + } + return { + base(Base) { + return assertCloudflareAgentClass(base(Base), Base, name, 'base(Agent)'); + }, + wrap(Final) { + return assertCloudflareAgentWrapper(wrap(Final), Final, name); + }, + }; +} + +function identity(value: T): T { + return value; +} + +function isCloudflareAgentExtension(value: unknown): value is CloudflareAgentExtension { + return ( + typeof value === 'object' && + value !== null && + CLOUDFLARE_AGENT_EXTENSION in value && + (value as BrandedCloudflareAgentExtension)[CLOUDFLARE_AGENT_EXTENSION] === true + ); +} + +function assertCloudflareAgentClass( + value: unknown, + Base: CloudflareAgentClass, + name: string, + source: string, +): CloudflareAgentClass { + if ( + typeof value !== 'function' || + (value !== Base && !(value.prototype instanceof Base)) || + !isConstructable(value as CloudflareAgentClass) + ) { + throw new Error( + `[flue] Agent "${name}" cloudflare.${source} must return the received class or a subclass.`, + ); + } + return value as CloudflareAgentClass; +} + +function assertCloudflareAgentWrapper( + value: unknown, + Final: CloudflareAgentClass, + name: string, +): CloudflareAgentClass { + if ( + typeof value !== 'function' || + (value !== Final && value.prototype !== Final.prototype) || + !isConstructable(value as CloudflareAgentClass) + ) { + throw new Error( + `[flue] Agent "${name}" cloudflare.wrap(Final) must return the received class or a constructor proxy.`, + ); + } + return value as CloudflareAgentClass; +} + +function isConstructable(value: CloudflareAgentClass): boolean { + try { + Reflect.construct(Function, [], value); + return true; + } catch { + return false; + } +} diff --git a/packages/runtime/src/cloudflare/index.ts b/packages/runtime/src/cloudflare/index.ts index cadaddd5..3b8bbf02 100644 --- a/packages/runtime/src/cloudflare/index.ts +++ b/packages/runtime/src/cloudflare/index.ts @@ -1,3 +1,9 @@ +export type { + CloudflareAgentClass, + CloudflareAgentExtension, + ResolvedCloudflareAgentExtension, +} from './agent-extension.ts'; +export { extend, resolveCloudflareAgentExtension } from './agent-extension.ts'; export type { VirtualSandboxOptions } from './virtual-sandbox.ts'; export { getVirtualSandbox } from './virtual-sandbox.ts'; diff --git a/packages/runtime/test/build-plugin-cloudflare.test.ts b/packages/runtime/test/build-plugin-cloudflare.test.ts new file mode 100644 index 00000000..2cdc6607 --- /dev/null +++ b/packages/runtime/test/build-plugin-cloudflare.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { CloudflarePlugin } from '../../cli/src/lib/build-plugin-cloudflare.ts'; +import type { BuildContext } from '../../cli/src/lib/types.ts'; + +describe('CloudflarePlugin', () => { + it('rejects generated Worker export collisions when an agent and workflow produce the same class name', async () => { + await expect( + new CloudflarePlugin().generateEntryPoint( + testBuildContext({ + agents: [{ name: 'draft-workflow', filePath: '/fixture/agents/draft-workflow.ts' }], + workflows: [{ name: 'draft', filePath: '/fixture/workflows/draft.ts' }], + }), + ), + ).rejects.toThrow( + 'Cloudflare target generated conflicting Worker export name(s): "DraftWorkflow" (agent "draft-workflow", workflow "draft")', + ); + }); +}); + +function testBuildContext(overrides: Partial = {}): BuildContext { + return { + agents: [], + workflows: [], + root: '/fixture', + output: '/fixture/dist', + runtimeVersion: '0.0.0-test', + options: { root: '/fixture', sourceRoot: '/fixture', target: 'cloudflare' }, + ...overrides, + }; +} diff --git a/packages/runtime/test/cloudflare-agent-extension.integration.test.ts b/packages/runtime/test/cloudflare-agent-extension.integration.test.ts new file mode 100644 index 00000000..9c704244 --- /dev/null +++ b/packages/runtime/test/cloudflare-agent-extension.integration.test.ts @@ -0,0 +1,155 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { createServer, type ViteDevServer } from 'vite'; +import { describe, expect, it } from 'vitest'; +import { + build, + cloudflareViteConfigPath, + cloudflareViteInputDir, + createCloudflareViteConfig, +} from '../../cli/src/lib/build.ts'; + +describe('Cloudflare agent extension', () => { + it('runs inherited scheduled callbacks when an agent module extends its base class', async () => { + const root = await createGeneratedFixture(); + let server: Awaited> | undefined; + try { + server = await startServer(root); + const { url } = server; + await waitFor(async () => { + const response = await fetch(new URL('/heartbeat', url)); + if (!response.ok) return { done: false, detail: await response.text() }; + const detail = (await response.json()) as { count: number }; + return { done: detail.count > 0, detail }; + }); + } finally { + await server?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }, 90000); + + it('wraps the final generated class without bypassing Flue-owned fetch handling', async () => { + const root = await createGeneratedFixture(); + let server: Awaited> | undefined; + try { + server = await startServer(root); + const response = await fetch(new URL('/agents/assistant/wrapped', server.url), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: 'Hello' }), + }); + expect(response.status).not.toBe(500); + const heartbeat = await fetch(new URL('/heartbeat', server.url)); + expect(heartbeat.status).toBe(200); + } finally { + await server?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }, 90000); + +}); + +async function createGeneratedFixture(agentSource = defaultAgentSource): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'flue-cloudflare-agent-extension-')); + const output = path.join(root, 'generated'); + fs.mkdirSync(path.join(root, 'node_modules', '@earendil-works'), { recursive: true }); + fs.mkdirSync(path.join(root, 'node_modules', '@flue'), { recursive: true }); + fs.symlinkSync( + path.resolve(process.cwd(), 'node_modules', '@earendil-works', 'pi-ai'), + path.join(root, 'node_modules', '@earendil-works', 'pi-ai'), + 'dir', + ); + fs.symlinkSync(process.cwd(), path.join(root, 'node_modules', '@flue', 'runtime'), 'dir'); + fs.symlinkSync( + path.resolve(process.cwd(), '../../examples/cloudflare-websocket/node_modules/agents'), + path.join(root, 'node_modules', 'agents'), + 'dir', + ); + fs.mkdirSync(path.join(root, 'src', 'agents'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'wrangler.jsonc'), + JSON.stringify({ + name: 'cloudflare-agent-extension', + compatibility_date: '2026-04-01', + compatibility_flags: ['nodejs_compat'], + migrations: [{ tag: 'v1', new_sqlite_classes: ['Assistant', 'FlueRegistry'] }], + }), + ); + fs.writeFileSync(path.join(root, 'src', 'agents', 'assistant.ts'), agentSource); + fs.writeFileSync( + path.join(root, 'src', 'app.ts'), + `import { getAgentByName, routeAgentRequest } from 'agents';\nlet started = false;\nexport default {\n async fetch(request, env) {\n const agentResponse = await routeAgentRequest(request, env);\n if (agentResponse) return agentResponse;\n const agent = await getAgentByName(env.Assistant, 'scheduled');\n if (!started) { await agent.startHeartbeat(); started = true; }\n return Response.json({ count: await agent.getHeartbeatCount() });\n },\n};\n`, + ); + try { + await build({ + root, + sourceRoot: path.join(root, 'src'), + output, + target: 'cloudflare', + mode: 'development', + }); + return root; + } catch (error) { + fs.rmSync(root, { recursive: true, force: true }); + throw error; + } +} + +async function startServer(root: string): Promise<{ url: string; close(): Promise }> { + const entryPath = path.join(cloudflareViteInputDir(root), '_entry.ts'); + const viteConfig = createCloudflareViteConfig(root, cloudflareViteConfigPath(root), [entryPath], { + persistState: false, + }); + const server: ViteDevServer = await createServer({ + ...viteConfig, + logLevel: 'silent', + server: { host: '127.0.0.1', port: 0 }, + }); + try { + await server.listen(); + const url = server.resolvedUrls?.local[0]; + if (!url) throw new Error('Vite server URL unavailable'); + return { url, close: () => server.close() }; + } catch (error) { + await server.close(); + throw error; + } +} + +async function waitFor( + predicate: () => Promise<{ done: boolean; detail: unknown }>, +): Promise { + const deadline = Date.now() + 10_000; + let detail: unknown; + while (Date.now() < deadline) { + const result = await predicate(); + detail = result.detail; + if (result.done) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for scheduled Cloudflare agent callback: ${JSON.stringify(detail)}`); +} + +const defaultAgentSource = `import { createAgent } from '@flue/runtime'; +import { extend } from '@flue/runtime/cloudflare'; +export default createAgent(() => ({ model: false })); +export const cloudflare = extend({ + base: (Base) => class extends Base { + async startHeartbeat() { return this.scheduleEvery(1, 'heartbeat'); } + async heartbeat() { this.setState({ count: (this.state?.count ?? 0) + 1 }); } + getHeartbeatCount() { return this.state?.count ?? 0; } + }, + wrap: (Final) => new Proxy(Final, { + construct(target, args) { + if (target.name !== 'Assistant') throw new Error('wrapper did not receive stable agent class identity'); + for (const method of ['onRequest', 'fetch', 'webSocketMessage', 'webSocketClose', 'webSocketError', 'onFiberRecovered']) { + if (!Object.prototype.hasOwnProperty.call(target.prototype, method)) { + throw new Error('wrapper did not receive generated Flue class'); + } + } + return new target(...args); + }, + }), +}); +`; diff --git a/packages/runtime/test/cloudflare-agent-extension.test.ts b/packages/runtime/test/cloudflare-agent-extension.test.ts new file mode 100644 index 00000000..f2d278d6 --- /dev/null +++ b/packages/runtime/test/cloudflare-agent-extension.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + extend, + type CloudflareAgentClass, + resolveCloudflareAgentExtension, +} from '../src/cloudflare/agent-extension.ts'; + +class Agent {} + +describe('resolveCloudflareAgentExtension()', () => { + it('defaults omitted extension callbacks to identity operations', () => { + const extension = resolveCloudflareAgentExtension({ cloudflare: extend({}) }, 'assistant'); + + expect(extension.base(Agent)).toBe(Agent); + expect(extension.wrap(Agent)).toBe(Agent); + }); + + it('accepts constructor proxies returned by wrap callbacks', () => { + const extension = resolveCloudflareAgentExtension( + { cloudflare: extend({ wrap: (Final) => new Proxy(Final, {}) }) }, + 'assistant', + ); + + expect(extension.wrap(Agent)).not.toBe(Agent); + }); + + it('rejects malformed agent cloudflare exports', () => { + expect(() => resolveCloudflareAgentExtension({ cloudflare: {} }, 'assistant')).toThrow( + 'cloudflare export must be created with extend({ base, wrap })', + ); + }); + + it('rejects malformed base callbacks', () => { + expect(() => + resolveCloudflareAgentExtension({ cloudflare: extend({ base: true as never }) }, 'assistant'), + ).toThrow('cloudflare.base must be a function'); + }); + + it('rejects malformed wrap callbacks', () => { + expect(() => + resolveCloudflareAgentExtension({ cloudflare: extend({ wrap: true as never }) }, 'assistant'), + ).toThrow('cloudflare.wrap must be a function'); + }); + + it('rejects base callbacks that return unrelated classes', () => { + const extension = resolveCloudflareAgentExtension( + { cloudflare: extend({ base: () => class {} }) }, + 'assistant', + ); + + expect(() => extension.base(Agent)).toThrow( + 'cloudflare.base(Agent) must return the received class or a subclass', + ); + }); + + it('rejects wrap callbacks that return unrelated classes', () => { + const extension = resolveCloudflareAgentExtension( + { cloudflare: extend({ wrap: () => class {} }) }, + 'assistant', + ); + + expect(() => extension.wrap(Agent)).toThrow( + 'cloudflare.wrap(Final) must return the received class or a constructor proxy', + ); + }); + + it('rejects wrap callbacks that return subclasses', () => { + const extension = resolveCloudflareAgentExtension( + { cloudflare: extend({ wrap: (Final) => class extends Final {} }) }, + 'assistant', + ); + + expect(() => extension.wrap(Agent)).toThrow( + 'cloudflare.wrap(Final) must return the received class or a constructor proxy', + ); + }); + + it('rejects non-constructable prototype-preserving wrappers', () => { + const extension = resolveCloudflareAgentExtension( + { + cloudflare: extend({ + wrap: (Final) => { + const wrapper = () => Final; + wrapper.prototype = Final.prototype; + return wrapper as never; + }, + }), + }, + 'assistant', + ); + + expect(() => extension.wrap(Agent)).toThrow( + 'cloudflare.wrap(Final) must return the received class or a constructor proxy', + ); + }); + + it('rejects malformed extend descriptors', () => { + expect(() => extend(null as never)).toThrow('extend() expects an object'); + expect(() => extend(undefined as never)).toThrow('extend() expects an object'); + expect(() => extend([] as never)).toThrow('extend() expects an object'); + }); + + it('rejects unknown extend descriptor options', () => { + expect(() => extend({ warp: (Final: CloudflareAgentClass) => Final } as never)).toThrow( + 'extend() received unknown option(s): warp', + ); + }); + + it('rejects legacy CloudflareAgent exports with migration guidance', () => { + expect(() => resolveCloudflareAgentExtension({ CloudflareAgent: Agent }, 'assistant')).toThrow( + 'CloudflareAgent export is no longer supported. Export cloudflare = extend({ base, wrap }) instead.', + ); + }); +}); diff --git a/packages/runtime/test/cloudflare-deployment-extension.integration.test.ts b/packages/runtime/test/cloudflare-deployment-extension.integration.test.ts new file mode 100644 index 00000000..6317031c --- /dev/null +++ b/packages/runtime/test/cloudflare-deployment-extension.integration.test.ts @@ -0,0 +1,215 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { createServer, type ViteDevServer } from 'vite'; +import { describe, expect, it } from 'vitest'; +import { + build, + cloudflareViteConfigPath, + cloudflareViteInputDir, + createCloudflareViteConfig, +} from '../../cli/src/lib/build.ts'; + +describe('Cloudflare deployment extensions', () => { + it('exports an authored Durable Object and composes non-HTTP Worker handlers', async () => { + const root = await createGeneratedFixture( + `import { DurableObject } from 'cloudflare:workers'; +export class Counter extends DurableObject { + async increment() { + const count = ((await this.ctx.storage.get('count')) ?? 0) + 1; + await this.ctx.storage.put('count', count); + return count; + } +} +export default { + async scheduled(_controller, env) { + await env.Counter.getByName('default').increment(); + }, +}; +`, + ); + let server: Awaited> | undefined; + try { + server = await startServer(root); + const response = await fetch(new URL('/counter', server.url)); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ count: 1 }); + const scheduled = await fetch(new URL('/cdn-cgi/handler/scheduled', server.url)); + expect(scheduled.status).toBe(200); + expect(await scheduled.text()).toBe('ok'); + const afterScheduled = await fetch(new URL('/counter', server.url)); + expect(afterScheduled.status).toBe(200); + expect(await afterScheduled.json()).toEqual({ count: 3 }); + } finally { + await server?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }, 90000); + + it('rejects fetch handlers authored in cloudflare.ts', async () => { + await expectRuntimeFailure( + `export default { async fetch() { return new Response('wrong'); } };\n`, + 'cloudflare.ts default export must not define fetch. Use app.ts for custom HTTP handling.', + ); + }, 90000); + + it('rejects invalid cloudflare.ts default exports', async () => { + const root = await createGeneratedFixture(`export default null;\n`); + try { + const entry = fs.readFileSync(path.join(cloudflareViteInputDir(root), '_entry.ts'), 'utf8'); + expect(entry).toContain( + `throw new Error('[flue] cloudflare.ts default export must be an object containing non-HTTP Worker handlers.');`, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 90000); + + it('rejects authored exports that conflict with generated Worker exports', async () => { + await expectRuntimeFailure( + `export class Assistant {}\n`, + 'cloudflare.ts export "Assistant" conflicts with a Flue-generated Worker export.', + ); + }, 90000); + + it('does not infer Sandbox exports from Wrangler class names', async () => { + const root = await createGeneratedFixture(`export const marker = true;\n`, { + durable_objects: { bindings: [{ name: 'Sandbox', class_name: 'Sandbox' }] }, + migrations: [ + { tag: 'v1', new_sqlite_classes: ['Assistant', 'FlueRegistry'] }, + { tag: 'v2', new_sqlite_classes: ['Sandbox'] }, + ], + }); + try { + const entry = fs.readFileSync(path.join(cloudflareViteInputDir(root), '_entry.ts'), 'utf8'); + expect(entry).not.toContain(`from '@cloudflare/sandbox'`); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 90000); + + it('rejects authored bindings that shadow generated Durable Object bindings', async () => { + await expect( + createGeneratedFixture(`export class Counter {}\n`, { + durable_objects: { bindings: [{ name: 'Assistant', class_name: 'Counter' }] }, + }), + ).rejects.toThrow( + 'wrangler.jsonc durable object binding "Assistant" is reserved by Flue. Expected class_name "Assistant", received "Counter".', + ); + }, 90000); + + it('does not update generated deployment inputs when Wrangler validation fails', async () => { + const root = await createGeneratedFixture(`export class Counter {}\n`); + const entryPath = path.join(cloudflareViteInputDir(root), '_entry.ts'); + const entry = fs.readFileSync(entryPath, 'utf8'); + try { + fs.writeFileSync( + path.join(root, 'src', 'agents', 'reviewer.ts'), + `import { createAgent } from '@flue/runtime';\nexport default createAgent(() => ({ model: false }));\n`, + ); + fs.writeFileSync( + path.join(root, 'wrangler.jsonc'), + JSON.stringify({ + compatibility_date: '2026-04-01', + compatibility_flags: ['nodejs_compat'], + durable_objects: { bindings: [{ name: 'Assistant', class_name: 'Counter' }] }, + }), + ); + await expect( + build({ + root, + sourceRoot: path.join(root, 'src'), + output: path.join(root, 'generated'), + target: 'cloudflare', + mode: 'development', + }), + ).rejects.toThrow('durable object binding "Assistant" is reserved by Flue'); + expect(fs.readFileSync(entryPath, 'utf8')).toBe(entry); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 90000); +}); + +async function expectRuntimeFailure(cloudflareSource: string, expected: string): Promise { + const root = await createGeneratedFixture(cloudflareSource); + try { + await expect(startServer(root)).rejects.toThrow(expected); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +async function createGeneratedFixture( + cloudflareSource: string, + wranglerOverrides: Record = {}, +): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'flue-cloudflare-deployment-extension-')); + const output = path.join(root, 'generated'); + fs.mkdirSync(path.join(root, 'node_modules', '@earendil-works'), { recursive: true }); + fs.mkdirSync(path.join(root, 'node_modules', '@flue'), { recursive: true }); + fs.symlinkSync( + path.resolve(process.cwd(), 'node_modules', '@earendil-works', 'pi-ai'), + path.join(root, 'node_modules', '@earendil-works', 'pi-ai'), + 'dir', + ); + fs.symlinkSync(process.cwd(), path.join(root, 'node_modules', '@flue', 'runtime'), 'dir'); + fs.symlinkSync( + path.resolve(process.cwd(), '../../examples/cloudflare-websocket/node_modules/agents'), + path.join(root, 'node_modules', 'agents'), + 'dir', + ); + fs.mkdirSync(path.join(root, 'src', 'agents'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'wrangler.jsonc'), + JSON.stringify({ + name: 'cloudflare-deployment-extension', + compatibility_date: '2026-04-01', + compatibility_flags: ['nodejs_compat'], + durable_objects: { bindings: [{ name: 'Counter', class_name: 'Counter' }] }, + migrations: [ + { tag: 'v1', new_sqlite_classes: ['Assistant', 'FlueRegistry'] }, + { tag: 'v2', new_sqlite_classes: ['Counter'] }, + ], + ...wranglerOverrides, + }), + ); + fs.writeFileSync( + path.join(root, 'src', 'agents', 'assistant.ts'), + `import { createAgent } from '@flue/runtime';\nexport default createAgent(() => ({ model: false }));\n`, + ); + fs.writeFileSync(path.join(root, 'src', 'cloudflare.ts'), cloudflareSource); + fs.writeFileSync( + path.join(root, 'src', 'app.ts'), + `export default {\n async fetch(_request, env) {\n const count = await env.Counter.getByName('default').increment();\n return Response.json({ count });\n },\n};\n`, + ); + try { + await build({ + root, + sourceRoot: path.join(root, 'src'), + output, + target: 'cloudflare', + mode: 'development', + }); + return root; + } catch (error) { + fs.rmSync(root, { recursive: true, force: true }); + throw error; + } +} + +async function startServer(root: string): Promise<{ url: string; close(): Promise }> { + const entryPath = path.join(cloudflareViteInputDir(root), '_entry.ts'); + const viteConfig = createCloudflareViteConfig(root, cloudflareViteConfigPath(root), [entryPath], { + persistState: false, + }); + const server: ViteDevServer = await createServer({ + ...viteConfig, + logLevel: 'silent', + server: { host: '127.0.0.1', port: 0 }, + }); + await server.listen(); + const url = server.resolvedUrls?.local[0]; + if (!url) throw new Error('Vite server URL unavailable'); + return { url, close: () => server.close() }; +} diff --git a/packages/runtime/test/package-entrypoints.test.ts b/packages/runtime/test/package-entrypoints.test.ts index 5f98f0a2..fa153422 100644 --- a/packages/runtime/test/package-entrypoints.test.ts +++ b/packages/runtime/test/package-entrypoints.test.ts @@ -68,6 +68,7 @@ describe('package entrypoints', () => { connectCloudflareAgentWebSocket: expect.any(Function), connectCloudflareWorkflowWebSocket: expect.any(Function), createCloudflareRunRegistry: expect.any(Function), + extend: expect.any(Function), FlueRegistry: expect.any(Function), getCloudflareAIBindingApiProvider: expect.any(Function), messageCloudflareAgentWebSocket: expect.any(Function), diff --git a/packages/runtime/vitest.integration.cloudflare.config.ts b/packages/runtime/vitest.integration.cloudflare.config.ts index 61087fdb..95b2cdbb 100644 --- a/packages/runtime/vitest.integration.cloudflare.config.ts +++ b/packages/runtime/vitest.integration.cloudflare.config.ts @@ -2,6 +2,10 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['test-legacy/vite-cloudflare-build.test.ts'], + include: [ + 'test/cloudflare-agent-extension.integration.test.ts', + 'test/cloudflare-deployment-extension.integration.test.ts', + 'test-legacy/vite-cloudflare-build.test.ts', + ], }, });