From 3cbc5cca0fae71be72362b2d26a45dde1d540b15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:07:51 +0000 Subject: [PATCH 1/5] Initial plan From 3c3554f46336f3a77d9b34109de2b29059ea447f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:12:22 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20add=20@aixyz/next=20package=20?= =?UTF-8?q?=E2=80=94=20Next.js=20Route=20Handler=20adapter=20for=20AixyzAp?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: fuxingloh <4266087+fuxingloh@users.noreply.github.com> --- packages/aixyz-next/.gitignore | 4 + packages/aixyz-next/index.test.ts | 142 ++++++++++++++++++++++++++++++ packages/aixyz-next/index.ts | 63 +++++++++++++ packages/aixyz-next/package.json | 39 ++++++++ packages/aixyz-next/tsconfig.json | 11 +++ 5 files changed, 259 insertions(+) create mode 100644 packages/aixyz-next/.gitignore create mode 100644 packages/aixyz-next/index.test.ts create mode 100644 packages/aixyz-next/index.ts create mode 100644 packages/aixyz-next/package.json create mode 100644 packages/aixyz-next/tsconfig.json diff --git a/packages/aixyz-next/.gitignore b/packages/aixyz-next/.gitignore new file mode 100644 index 00000000..4eb47398 --- /dev/null +++ b/packages/aixyz-next/.gitignore @@ -0,0 +1,4 @@ +# Build output +*.js +*.d.ts +*.d.ts.map diff --git a/packages/aixyz-next/index.test.ts b/packages/aixyz-next/index.test.ts new file mode 100644 index 00000000..af2aa9fd --- /dev/null +++ b/packages/aixyz-next/index.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, mock, test } from "bun:test"; + +mock.module("@aixyz/config", () => ({ + getAixyzConfig: () => ({ + name: "Test Agent", + description: "A test agent", + version: "1.0.0", + url: "http://localhost:3000", + x402: { payTo: "0x0000000000000000000000000000000000000000", network: "eip155:8453" }, + build: { tools: [], agents: [], excludes: [] }, + vercel: { maxDuration: 30 }, + skills: [], + }), + getAixyzConfigRuntime: () => ({ + name: "Test Agent", + description: "A test agent", + version: "1.0.0", + url: "http://localhost:3000", + skills: [], + }), +})); + +import { AixyzApp } from "aixyz/app"; +import { toNextRouteHandler } from "./index"; + +describe("toNextRouteHandler", () => { + test("returns handlers for all standard HTTP methods", () => { + const app = new AixyzApp(); + const handlers = toNextRouteHandler(app); + + expect(typeof handlers.GET).toBe("function"); + expect(typeof handlers.POST).toBe("function"); + expect(typeof handlers.PUT).toBe("function"); + expect(typeof handlers.DELETE).toBe("function"); + expect(typeof handlers.PATCH).toBe("function"); + expect(typeof handlers.HEAD).toBe("function"); + expect(typeof handlers.OPTIONS).toBe("function"); + }); + + test("GET handler dispatches to app.fetch and returns response", async () => { + const app = new AixyzApp(); + app.route("GET", "/hello", () => new Response("world", { status: 200 })); + + const { GET } = toNextRouteHandler(app); + const res = await GET(new Request("http://localhost/hello")); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("world"); + }); + + test("POST handler dispatches to app.fetch and returns response", async () => { + const app = new AixyzApp(); + app.route("POST", "/submit", () => new Response("ok", { status: 201 })); + + const { POST } = toNextRouteHandler(app); + const res = await POST(new Request("http://localhost/submit", { method: "POST" })); + + expect(res.status).toBe(201); + expect(await res.text()).toBe("ok"); + }); + + test("returns 404 for unregistered routes", async () => { + const app = new AixyzApp(); + const { GET } = toNextRouteHandler(app); + + const res = await GET(new Request("http://localhost/missing")); + expect(res.status).toBe(404); + }); + + test("all method handlers share the same underlying fetch", async () => { + const app = new AixyzApp(); + app.route("GET", "/ping", () => new Response("pong")); + app.route("POST", "/ping", () => new Response("pong-post")); + + const handlers = toNextRouteHandler(app); + + const getRes = await handlers.GET(new Request("http://localhost/ping", { method: "GET" })); + expect(getRes.status).toBe(200); + expect(await getRes.text()).toBe("pong"); + + const postRes = await handlers.POST(new Request("http://localhost/ping", { method: "POST" })); + expect(postRes.status).toBe(200); + expect(await postRes.text()).toBe("pong-post"); + }); + + test("PUT handler dispatches correctly", async () => { + const app = new AixyzApp(); + app.route("PUT", "/item", () => new Response("updated")); + + const { PUT } = toNextRouteHandler(app); + const res = await PUT(new Request("http://localhost/item", { method: "PUT" })); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("updated"); + }); + + test("DELETE handler dispatches correctly", async () => { + const app = new AixyzApp(); + app.route("DELETE", "/item", () => new Response("deleted")); + + const { DELETE } = toNextRouteHandler(app); + const res = await DELETE(new Request("http://localhost/item", { method: "DELETE" })); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("deleted"); + }); + + test("middleware applied to all handlers", async () => { + const app = new AixyzApp(); + app.use(async (_req, next) => { + const res = await next(); + return new Response(await res.text(), { + status: res.status, + headers: { ...Object.fromEntries(res.headers), "x-next-adapter": "true" }, + }); + }); + app.route("GET", "/mw", () => new Response("ok")); + + const { GET } = toNextRouteHandler(app); + const res = await GET(new Request("http://localhost/mw")); + + expect(res.headers.get("x-next-adapter")).toBe("true"); + expect(await res.text()).toBe("ok"); + }); + + test("handlers can be destructured and used as named exports", async () => { + const app = new AixyzApp(); + app.route("GET", "/", () => Response.json({ status: "ok" })); + + // Simulates: export const { GET, POST } = toNextRouteHandler(app); + const { GET, POST } = toNextRouteHandler(app); + + const res = await GET(new Request("http://localhost/")); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toEqual({ status: "ok" }); + + // POST not registered → 404 + const notFound = await POST(new Request("http://localhost/", { method: "POST" })); + expect(notFound.status).toBe(404); + }); +}); diff --git a/packages/aixyz-next/index.ts b/packages/aixyz-next/index.ts new file mode 100644 index 00000000..6542819d --- /dev/null +++ b/packages/aixyz-next/index.ts @@ -0,0 +1,63 @@ +import type { AixyzApp } from "aixyz/app"; + +export type NextRouteHandler = (request: Request) => Promise; + +export interface NextRouteHandlers { + GET: NextRouteHandler; + POST: NextRouteHandler; + PUT: NextRouteHandler; + DELETE: NextRouteHandler; + PATCH: NextRouteHandler; + HEAD: NextRouteHandler; + OPTIONS: NextRouteHandler; +} + +/** + * Converts an {@link AixyzApp} into Next.js Route Handler exports. + * + * This adapter bridges the web-standard `Request`/`Response` API used by + * `AixyzApp` with Next.js App Router Route Handlers. Because both Next.js + * and `AixyzApp` use web-standard `Request`/`Response`, no conversion is + * needed — requests are forwarded directly to `app.fetch()`. + * + * The returned object contains named exports for every HTTP method + * (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`) so you can + * spread or destructure them directly in your route file. + * + * **Important:** Call `app.initialize()` before passing the app to this + * function, or call it once at module level so initialization runs at cold + * start rather than per-request. + * + * @param app - A fully initialized {@link AixyzApp} instance. + * @returns An object of Next.js Route Handler functions keyed by HTTP method. + * + * @example + * ```ts + * // app/api/[[...route]]/route.ts + * import { toNextRouteHandler } from "@aixyz/next"; + * import { AixyzApp } from "aixyz/app"; + * import { A2APlugin } from "aixyz/app/plugins/a2a"; + * import { MCPPlugin } from "aixyz/app/plugins/mcp"; + * import * as agent from "../../agent"; + * + * const app = new AixyzApp(); + * await app.withPlugin(new A2APlugin(agent)); + * await app.withPlugin(new MCPPlugin([{ name: "agent", exports: agent }])); + * await app.initialize(); + * + * export const { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } = toNextRouteHandler(app); + * ``` + */ +export function toNextRouteHandler(app: AixyzApp): NextRouteHandlers { + const handler: NextRouteHandler = (request: Request) => app.fetch(request); + + return { + GET: handler, + POST: handler, + PUT: handler, + DELETE: handler, + PATCH: handler, + HEAD: handler, + OPTIONS: handler, + }; +} diff --git a/packages/aixyz-next/package.json b/packages/aixyz-next/package.json new file mode 100644 index 00000000..684dd884 --- /dev/null +++ b/packages/aixyz-next/package.json @@ -0,0 +1,39 @@ +{ + "name": "@aixyz/next", + "version": "0.0.0", + "description": "Next.js adapter for AixyzApp", + "keywords": [ + "ai", + "agent", + "aixyz", + "next", + "nextjs" + ], + "homepage": "https://aixyz.sh", + "bugs": "https://github.com/AgentlyHQ/aixyz/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/AgentlyHQ/aixyz.git" + }, + "license": "MIT", + "author": "AgentlyHQ", + "type": "module", + "exports": { + ".": "./index.ts" + }, + "files": [ + "**/*.ts", + "!**/*.test.ts" + ], + "scripts": { + "test": "bun test" + }, + "devDependencies": { + "aixyz": "workspace:*", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "aixyz": "workspace:*", + "next": ">=14" + } +} diff --git a/packages/aixyz-next/tsconfig.json b/packages/aixyz-next/tsconfig.json new file mode 100644 index 00000000..8488142f --- /dev/null +++ b/packages/aixyz-next/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true + } +} From 89aa4c0fba8caf76bbd9e0613834b8d881b1fa41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:43:47 +0000 Subject: [PATCH 3/5] feat: improve @aixyz/next with magical createNextHandler API Co-authored-by: fuxingloh <4266087+fuxingloh@users.noreply.github.com> --- bun.lock | 63 +++++++++ packages/aixyz-next/index.test.ts | 204 ++++++++++++++++++++++-------- packages/aixyz-next/index.ts | 124 ++++++++++++++++-- packages/aixyz-next/package.json | 11 +- 4 files changed, 337 insertions(+), 65 deletions(-) diff --git a/bun.lock b/bun.lock index 7db75707..9fc428ed 100644 --- a/bun.lock +++ b/bun.lock @@ -243,6 +243,25 @@ "zod": "^4", }, }, + "packages/aixyz-next": { + "name": "@aixyz/next", + "version": "0.0.0", + "devDependencies": { + "@aixyz/config": "workspace:*", + "ai": "^6", + "aixyz": "workspace:*", + "typescript": "^5.9.3", + "zod": "^4", + }, + "peerDependencies": { + "ai": "^6", + "aixyz": "workspace:*", + "next": ">=14", + }, + "optionalPeers": [ + "ai", + ], + }, "packages/aixyz-stripe": { "name": "@aixyz/stripe", "version": "0.0.0", @@ -297,6 +316,8 @@ "@aixyz/erc-8004": ["@aixyz/erc-8004@workspace:packages/aixyz-erc-8004"], + "@aixyz/next": ["@aixyz/next@workspace:packages/aixyz-next"], + "@aixyz/stripe": ["@aixyz/stripe@workspace:packages/aixyz-stripe"], "@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="], @@ -437,6 +458,22 @@ "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], @@ -485,6 +522,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], @@ -585,6 +624,8 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="], + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], @@ -619,6 +660,8 @@ "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], @@ -633,6 +676,8 @@ "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -897,8 +942,12 @@ "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -941,6 +990,8 @@ "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "prettier-plugin-packagejson": ["prettier-plugin-packagejson@3.0.0", "", { "dependencies": { "sort-package-json": "3.6.0" }, "peerDependencies": { "prettier": "^3" }, "optionalPeers": ["prettier"] }, "sha512-z8/QmPSqx/ANvvQMWJSkSq1+ihBXeuwDEYdjX3ZjRJ5Ty1k7vGbFQfhzk2eDe0rwS/TNyRjWK/qnjJEStAOtDw=="], @@ -967,6 +1018,10 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], @@ -989,6 +1044,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], @@ -1027,6 +1084,8 @@ "sort-package-json": ["sort-package-json@3.6.0", "", { "dependencies": { "detect-indent": "^7.0.2", "detect-newline": "^4.0.1", "git-hooks-list": "^4.1.1", "is-plain-obj": "^4.1.0", "semver": "^7.7.3", "sort-object-keys": "^2.0.1", "tinyglobby": "^0.2.15" }, "bin": { "sort-package-json": "cli.js" } }, "sha512-fyJsPLhWvY7u2KsKPZn1PixbXp+1m7V8NWqU8CvgFRbMEX41Ffw1kD8n0CfJiGoaSfoAvbrqRRl/DcHO8omQOQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="], "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], @@ -1053,6 +1112,8 @@ "stripe": ["stripe@20.3.1", "", { "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="], "tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], @@ -1175,6 +1236,8 @@ "@spruceid/siwe-parser/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@use-agently/sdk/viem": ["viem@2.46.3", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.4", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg=="], diff --git a/packages/aixyz-next/index.test.ts b/packages/aixyz-next/index.test.ts index af2aa9fd..da2231d5 100644 --- a/packages/aixyz-next/index.test.ts +++ b/packages/aixyz-next/index.test.ts @@ -20,8 +20,151 @@ mock.module("@aixyz/config", () => ({ }), })); +mock.module("aixyz/accepts", () => ({ + // null facilitator: disables x402 in unit tests so we can focus on plugin wiring + facilitator: null, + AcceptsScheme: { + parse: (v: unknown) => v, + }, + HTTPFacilitatorClient: class {}, +})); + +import { tool } from "ai"; +import { z } from "zod"; import { AixyzApp } from "aixyz/app"; -import { toNextRouteHandler } from "./index"; +import { createNextHandler, toNextRouteHandler } from "./index"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMockAgent(text = "Hello world") { + return { + stream: async () => ({ + textStream: (async function* () { + yield text; + })(), + }), + } as any; +} + +function makeMockTool(result = "tool result") { + return tool({ + description: "A mock tool", + parameters: z.object({ input: z.string() }), + execute: async () => result, + }); +} + +// --------------------------------------------------------------------------- +// createNextHandler — the high-level "magical" API +// --------------------------------------------------------------------------- + +describe("createNextHandler", () => { + test("returns handlers for all standard HTTP methods", () => { + const handlers = createNextHandler(); + expect(typeof handlers.GET).toBe("function"); + expect(typeof handlers.POST).toBe("function"); + expect(typeof handlers.PUT).toBe("function"); + expect(typeof handlers.DELETE).toBe("function"); + expect(typeof handlers.PATCH).toBe("function"); + expect(typeof handlers.HEAD).toBe("function"); + expect(typeof handlers.OPTIONS).toBe("function"); + }); + + test("returns 404 for unregistered path with no plugins", async () => { + const { GET } = createNextHandler(); + const res = await GET(new Request("http://localhost/missing")); + expect(res.status).toBe(404); + }); + + test("auto-registers A2A well-known and agent routes when agent provided", async () => { + const { GET, POST } = createNextHandler({ + agent: { default: makeMockAgent(), accepts: { scheme: "free" } }, + }); + + const card = await GET(new Request("http://localhost/.well-known/agent-card.json")); + expect(card.status).toBe(200); + const json = await card.json(); + expect(json.name).toBe("Test Agent"); + + // POST /agent responds (returns a JSON-RPC result) + const agentRes = await POST( + new Request("http://localhost/agent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "message/send", + params: { + message: { + kind: "message", + messageId: "msg-1", + role: "user", + parts: [{ kind: "text", text: "hi" }], + }, + }, + }), + }), + ); + expect(agentRes.status).toBe(200); + }); + + test("auto-registers IndexPage route at /", async () => { + const { GET } = createNextHandler({ + agent: { default: makeMockAgent(), accepts: { scheme: "free" } }, + }); + + const res = await GET(new Request("http://localhost/")); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toContain("Test Agent"); + }); + + test("auto-registers MCP routes when tools provided", async () => { + const { POST } = createNextHandler({ + tools: [{ name: "mock", exports: { default: makeMockTool() } }], + }); + + const res = await POST(new Request("http://localhost/mcp", { method: "POST" })); + // MCP endpoint exists (not 404) + expect(res.status).not.toBe(404); + }); + + test("lazy-initializes — app is built once across concurrent requests", async () => { + const { GET } = createNextHandler({ + agent: { default: makeMockAgent(), accepts: { scheme: "free" } }, + }); + + // Fire concurrent requests; if app were re-initialized each time it would + // rebuild the route table and the second request might race incorrectly. + const [r1, r2] = await Promise.all([ + GET(new Request("http://localhost/.well-known/agent-card.json")), + GET(new Request("http://localhost/.well-known/agent-card.json")), + ]); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + }); + + test("handlers can be destructured as named exports", async () => { + const { GET, POST } = createNextHandler({ + agent: { default: makeMockAgent(), accepts: { scheme: "free" } }, + }); + + const card = await GET(new Request("http://localhost/.well-known/agent-card.json")); + expect(card.status).toBe(200); + + // GET /agent should 404 (only POST registered by A2APlugin) + const notFound = await GET(new Request("http://localhost/agent")); + expect(notFound.status).toBe(404); + void POST; + }); +}); + +// --------------------------------------------------------------------------- +// toNextRouteHandler — the low-level escape-hatch +// --------------------------------------------------------------------------- describe("toNextRouteHandler", () => { test("returns handlers for all standard HTTP methods", () => { @@ -67,45 +210,23 @@ describe("toNextRouteHandler", () => { expect(res.status).toBe(404); }); - test("all method handlers share the same underlying fetch", async () => { - const app = new AixyzApp(); - app.route("GET", "/ping", () => new Response("pong")); - app.route("POST", "/ping", () => new Response("pong-post")); - - const handlers = toNextRouteHandler(app); - - const getRes = await handlers.GET(new Request("http://localhost/ping", { method: "GET" })); - expect(getRes.status).toBe(200); - expect(await getRes.text()).toBe("pong"); - - const postRes = await handlers.POST(new Request("http://localhost/ping", { method: "POST" })); - expect(postRes.status).toBe(200); - expect(await postRes.text()).toBe("pong-post"); - }); - - test("PUT handler dispatches correctly", async () => { + test("PUT, DELETE handlers dispatch correctly", async () => { const app = new AixyzApp(); app.route("PUT", "/item", () => new Response("updated")); - - const { PUT } = toNextRouteHandler(app); - const res = await PUT(new Request("http://localhost/item", { method: "PUT" })); - - expect(res.status).toBe(200); - expect(await res.text()).toBe("updated"); - }); - - test("DELETE handler dispatches correctly", async () => { - const app = new AixyzApp(); app.route("DELETE", "/item", () => new Response("deleted")); - const { DELETE } = toNextRouteHandler(app); - const res = await DELETE(new Request("http://localhost/item", { method: "DELETE" })); + const { PUT, DELETE } = toNextRouteHandler(app); - expect(res.status).toBe(200); - expect(await res.text()).toBe("deleted"); + const putRes = await PUT(new Request("http://localhost/item", { method: "PUT" })); + expect(putRes.status).toBe(200); + expect(await putRes.text()).toBe("updated"); + + const delRes = await DELETE(new Request("http://localhost/item", { method: "DELETE" })); + expect(delRes.status).toBe(200); + expect(await delRes.text()).toBe("deleted"); }); - test("middleware applied to all handlers", async () => { + test("middleware runs through all handlers", async () => { const app = new AixyzApp(); app.use(async (_req, next) => { const res = await next(); @@ -122,21 +243,4 @@ describe("toNextRouteHandler", () => { expect(res.headers.get("x-next-adapter")).toBe("true"); expect(await res.text()).toBe("ok"); }); - - test("handlers can be destructured and used as named exports", async () => { - const app = new AixyzApp(); - app.route("GET", "/", () => Response.json({ status: "ok" })); - - // Simulates: export const { GET, POST } = toNextRouteHandler(app); - const { GET, POST } = toNextRouteHandler(app); - - const res = await GET(new Request("http://localhost/")); - expect(res.status).toBe(200); - const json = await res.json(); - expect(json).toEqual({ status: "ok" }); - - // POST not registered → 404 - const notFound = await POST(new Request("http://localhost/", { method: "POST" })); - expect(notFound.status).toBe(404); - }); }); diff --git a/packages/aixyz-next/index.ts b/packages/aixyz-next/index.ts index 6542819d..c417928d 100644 --- a/packages/aixyz-next/index.ts +++ b/packages/aixyz-next/index.ts @@ -1,4 +1,10 @@ -import type { AixyzApp } from "aixyz/app"; +import { AixyzApp } from "aixyz/app"; +import { A2APlugin } from "aixyz/app/plugins/a2a"; +import { MCPPlugin } from "aixyz/app/plugins/mcp"; +import { IndexPagePlugin } from "aixyz/app/plugins/index-page"; +import { facilitator } from "aixyz/accepts"; +import type { Accepts } from "aixyz/accepts"; +import type { Tool, ToolLoopAgent, ToolSet } from "ai"; export type NextRouteHandler = (request: Request) => Promise; @@ -13,20 +19,112 @@ export interface NextRouteHandlers { } /** - * Converts an {@link AixyzApp} into Next.js Route Handler exports. + * Agent module shape — the named exports of an `app/agent.ts` file. + * The `default` export is the ToolLoopAgent; `accepts` controls x402 payment gating. + */ +export interface AgentModule { + default: ToolLoopAgent; + accepts?: Accepts; +} + +/** + * Tool module shape — the named exports of a file in `app/tools/`. + * The `default` export is the Tool; `accepts` controls per-tool MCP payment gating. + */ +export interface ToolModule { + default: Tool; + accepts?: Accepts; +} + +/** + * Options for {@link createNextHandler}. + */ +export interface CreateNextHandlerOptions { + /** + * Root agent module (re-export of `app/agent.ts`). + * Wires up an A2A endpoint (`/agent`) and the well-known agent card. + */ + agent?: AgentModule; + /** + * Tool modules to expose over MCP (`/mcp`). + * Each entry needs a `name` (used as the MCP tool name) and the tool's module exports. + */ + tools?: Array<{ name: string; exports: ToolModule }>; +} + +/** + * Create Next.js App Router Route Handler exports from agent and tool modules — with zero boilerplate. + * + * Pass your `app/agent.ts` and `app/tools/*.ts` module exports and get back ready-to-use + * HTTP method handlers. Plugins (`A2APlugin`, `MCPPlugin`, `IndexPagePlugin`) are wired up + * automatically, and the app is lazy-initialized on the first request so cold starts are + * not penalised. * - * This adapter bridges the web-standard `Request`/`Response` API used by - * `AixyzApp` with Next.js App Router Route Handlers. Because both Next.js - * and `AixyzApp` use web-standard `Request`/`Response`, no conversion is - * needed — requests are forwarded directly to `app.fetch()`. + * @example + * ```ts + * // app/api/[[...route]]/route.ts + * import { createNextHandler } from "@aixyz/next"; + * import * as agent from "../../agent"; + * import * as weatherTool from "../../tools/weather"; + * + * export const { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } = createNextHandler({ + * agent, + * tools: [{ name: "weather", exports: weatherTool }], + * }); + * ``` + * + * @param options - Agent and tool module exports to wire up. + * @returns An object of Next.js Route Handler functions keyed by HTTP method. + */ +export function createNextHandler(options: CreateNextHandlerOptions = {}): NextRouteHandlers { + let initPromise: Promise | null = null; + + async function getApp(): Promise { + if (initPromise) return initPromise; + + initPromise = (async () => { + const app = new AixyzApp(facilitator ? { facilitators: facilitator } : undefined); + await app.withPlugin(new IndexPagePlugin()); + + if (options.agent) { + await app.withPlugin(new A2APlugin(options.agent)); + } + + if (options.tools && options.tools.length > 0) { + await app.withPlugin(new MCPPlugin(options.tools)); + } + + await app.initialize(); + return app; + })(); + + return initPromise; + } + + const handler: NextRouteHandler = async (request: Request) => { + const app = await getApp(); + return app.fetch(request); + }; + + return { + GET: handler, + POST: handler, + PUT: handler, + DELETE: handler, + PATCH: handler, + HEAD: handler, + OPTIONS: handler, + }; +} + +/** + * Low-level adapter: converts a fully-configured {@link AixyzApp} instance into + * Next.js App Router Route Handler exports. * - * The returned object contains named exports for every HTTP method - * (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`) so you can - * spread or destructure them directly in your route file. + * Use {@link createNextHandler} unless you need full control over the `AixyzApp` + * setup (custom plugins, custom payment facilitators, etc.). * - * **Important:** Call `app.initialize()` before passing the app to this - * function, or call it once at module level so initialization runs at cold - * start rather than per-request. + * **Important:** Call `app.initialize()` before passing the app here. * * @param app - A fully initialized {@link AixyzApp} instance. * @returns An object of Next.js Route Handler functions keyed by HTTP method. @@ -37,12 +135,10 @@ export interface NextRouteHandlers { * import { toNextRouteHandler } from "@aixyz/next"; * import { AixyzApp } from "aixyz/app"; * import { A2APlugin } from "aixyz/app/plugins/a2a"; - * import { MCPPlugin } from "aixyz/app/plugins/mcp"; * import * as agent from "../../agent"; * * const app = new AixyzApp(); * await app.withPlugin(new A2APlugin(agent)); - * await app.withPlugin(new MCPPlugin([{ name: "agent", exports: agent }])); * await app.initialize(); * * export const { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } = toNextRouteHandler(app); diff --git a/packages/aixyz-next/package.json b/packages/aixyz-next/package.json index 684dd884..40d410e2 100644 --- a/packages/aixyz-next/package.json +++ b/packages/aixyz-next/package.json @@ -29,11 +29,20 @@ "test": "bun test" }, "devDependencies": { + "@aixyz/config": "workspace:*", + "ai": "^6", "aixyz": "workspace:*", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "zod": "^4" }, "peerDependencies": { + "ai": "^6", "aixyz": "workspace:*", "next": ">=14" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + } } } From ea8aab43ed062754443a55fc67301c8ccd8763a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:24:18 +0000 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20add=20withAixyzConfig=20=E2=80=94?= =?UTF-8?q?=20zero-config=20Next.js=20adapter=20via=20next.config.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: fuxingloh <4266087+fuxingloh@users.noreply.github.com> --- app/api/[[...route]]/route.ts | 2 + packages/aixyz-next/index.test.ts | 206 +++++++++++++++++++++++++++++- packages/aixyz-next/index.ts | 157 +++++++++++++++++++++++ packages/aixyz-next/package.json | 3 +- packages/aixyz-next/route.ts | 25 ++++ 5 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 app/api/[[...route]]/route.ts create mode 100644 packages/aixyz-next/route.ts diff --git a/app/api/[[...route]]/route.ts b/app/api/[[...route]]/route.ts new file mode 100644 index 00000000..2bf3bf7a --- /dev/null +++ b/app/api/[[...route]]/route.ts @@ -0,0 +1,2 @@ +// Auto-generated by @aixyz/next — safe to commit +export { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } from "@aixyz/next/route"; diff --git a/packages/aixyz-next/index.test.ts b/packages/aixyz-next/index.test.ts index da2231d5..e11aff8c 100644 --- a/packages/aixyz-next/index.test.ts +++ b/packages/aixyz-next/index.test.ts @@ -29,10 +29,13 @@ mock.module("aixyz/accepts", () => ({ HTTPFacilitatorClient: class {}, })); +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { tool } from "ai"; import { z } from "zod"; import { AixyzApp } from "aixyz/app"; -import { createNextHandler, toNextRouteHandler } from "./index"; +import { createNextHandler, ensureRouteFile, generateAixyzRoute, toNextRouteHandler, withAixyzConfig } from "./index"; // --------------------------------------------------------------------------- // Helpers @@ -56,6 +59,207 @@ function makeMockTool(result = "tool result") { }); } +/** Create a temp project directory for filesystem tests. */ +function makeTempProject(): string { + const dir = join(tmpdir(), `aixyz-next-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(dir, ".next", "cache", "aixyz"), { recursive: true }); + return dir; +} + +// --------------------------------------------------------------------------- +// withAixyzConfig — Next.js config wrapper +// --------------------------------------------------------------------------- + +describe("withAixyzConfig", () => { + test("returns a config object with a webpack function", () => { + const dir = makeTempProject(); + mkdirSync(join(dir, "app"), { recursive: true }); + + // withAixyzConfig runs against process.cwd(); test the returned config shape. + const config = withAixyzConfig({}); + expect(typeof config).toBe("object"); + expect(typeof config.webpack).toBe("function"); + rmSync(dir, { recursive: true, force: true }); + }); + + test("webpack function adds @aixyz/next/route alias", () => { + const config = withAixyzConfig({}); + const webpackConfig: any = { resolve: { alias: {} } }; + const result = (config.webpack as any)(webpackConfig, { isServer: true, dir: process.cwd() }); + expect(typeof result.resolve.alias["@aixyz/next/route"]).toBe("string"); + expect(result.resolve.alias["@aixyz/next/route"]).toContain("route.mjs"); + }); + + test("webpack function calls through user-provided webpack", () => { + let called = false; + const userWebpack = (cfg: any) => { + called = true; + return cfg; + }; + const config = withAixyzConfig({ webpack: userWebpack }); + const webpackConfig: any = { resolve: { alias: {} } }; + (config.webpack as any)(webpackConfig, { isServer: true, dir: process.cwd() }); + expect(called).toBe(true); + }); + + test("preserves user config keys", () => { + const config = withAixyzConfig({ reactStrictMode: true, swcMinify: true } as any); + expect((config as any).reactStrictMode).toBe(true); + expect((config as any).swcMinify).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// ensureRouteFile — auto-creates app/api/[[...route]]/route.ts +// --------------------------------------------------------------------------- + +describe("ensureRouteFile", () => { + test("creates route file when it does not exist", () => { + const dir = makeTempProject(); + mkdirSync(join(dir, "app"), { recursive: true }); + + ensureRouteFile(dir); + + const routeFile = join(dir, "app", "api", "[[...route]]", "route.ts"); + expect(existsSync(routeFile)).toBe(true); + const content = readFileSync(routeFile, "utf-8"); + expect(content).toContain("@aixyz/next/route"); + expect(content).toContain("GET"); + + rmSync(dir, { recursive: true, force: true }); + }); + + test("does not overwrite an existing route file", () => { + const dir = makeTempProject(); + const routeDir = join(dir, "app", "api", "[[...route]]"); + mkdirSync(routeDir, { recursive: true }); + const routeFile = join(routeDir, "route.ts"); + writeFileSync(routeFile, "// custom\n"); + + ensureRouteFile(dir); + + expect(readFileSync(routeFile, "utf-8")).toBe("// custom\n"); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// generateAixyzRoute — scans app/ and emits .next/cache/aixyz/route.mjs +// --------------------------------------------------------------------------- + +describe("generateAixyzRoute", () => { + test("generates minimal handler when app/ has no agent or tools", () => { + const dir = makeTempProject(); + mkdirSync(join(dir, "app"), { recursive: true }); + + const routePath = generateAixyzRoute(dir); + + expect(existsSync(routePath)).toBe(true); + const code = readFileSync(routePath, "utf-8"); + expect(code).toContain(`import { createNextHandler } from "@aixyz/next"`); + expect(code).toContain("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"); + expect(code).not.toContain("__agent"); + expect(code).not.toContain("__tool_"); + + rmSync(dir, { recursive: true, force: true }); + }); + + test("includes agent import when app/agent.ts exists", () => { + const dir = makeTempProject(); + const appDir = join(dir, "app"); + mkdirSync(appDir, { recursive: true }); + writeFileSync(join(appDir, "agent.ts"), "export default {}; export const accepts = { scheme: 'free' };"); + + const routePath = generateAixyzRoute(dir); + const code = readFileSync(routePath, "utf-8"); + + expect(code).toContain("import * as __agent"); + expect(code).toContain("agent.ts"); + expect(code).toContain("agent: __agent"); + + rmSync(dir, { recursive: true, force: true }); + }); + + test("includes agent import when app/agent.js exists", () => { + const dir = makeTempProject(); + const appDir = join(dir, "app"); + mkdirSync(appDir, { recursive: true }); + writeFileSync(join(appDir, "agent.js"), "exports.default = {};"); + + const routePath = generateAixyzRoute(dir); + const code = readFileSync(routePath, "utf-8"); + + expect(code).toContain("agent.js"); + expect(code).toContain("agent: __agent"); + + rmSync(dir, { recursive: true, force: true }); + }); + + test("includes tool imports when app/tools/*.ts exist", () => { + const dir = makeTempProject(); + const toolsDir = join(dir, "app", "tools"); + mkdirSync(toolsDir, { recursive: true }); + writeFileSync(join(toolsDir, "weather.ts"), "export default {};"); + writeFileSync(join(toolsDir, "search.ts"), "export default {};"); + + const routePath = generateAixyzRoute(dir); + const code = readFileSync(routePath, "utf-8"); + + expect(code).toContain("__tool_weather"); + expect(code).toContain("__tool_search"); + expect(code).toContain(`name: "weather"`); + expect(code).toContain(`name: "search"`); + + rmSync(dir, { recursive: true, force: true }); + }); + + test("skips tool files starting with underscore", () => { + const dir = makeTempProject(); + const toolsDir = join(dir, "app", "tools"); + mkdirSync(toolsDir, { recursive: true }); + writeFileSync(join(toolsDir, "weather.ts"), "export default {};"); + writeFileSync(join(toolsDir, "_private.ts"), "export default {};"); + writeFileSync(join(toolsDir, "_helper.ts"), "export default {};"); + + const routePath = generateAixyzRoute(dir); + const code = readFileSync(routePath, "utf-8"); + + expect(code).toContain("__tool_weather"); + expect(code).not.toContain("_private"); + expect(code).not.toContain("_helper"); + + rmSync(dir, { recursive: true, force: true }); + }); + + test("generated code uses correct tool names for kebab-case files", () => { + const dir = makeTempProject(); + const toolsDir = join(dir, "app", "tools"); + mkdirSync(toolsDir, { recursive: true }); + writeFileSync(join(toolsDir, "get-weather.ts"), "export default {};"); + + const routePath = generateAixyzRoute(dir); + const code = readFileSync(routePath, "utf-8"); + + // Identifier uses underscores; name stays kebab-case + expect(code).toContain("__tool_get_weather"); + expect(code).toContain(`name: "get-weather"`); + + rmSync(dir, { recursive: true, force: true }); + }); + + test("generated file is written to .next/cache/aixyz/route.mjs", () => { + const dir = makeTempProject(); + mkdirSync(join(dir, "app"), { recursive: true }); + + const routePath = generateAixyzRoute(dir); + + expect(routePath).toContain(join(".next", "cache", "aixyz", "route.mjs")); + expect(existsSync(routePath)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + // --------------------------------------------------------------------------- // createNextHandler — the high-level "magical" API // --------------------------------------------------------------------------- diff --git a/packages/aixyz-next/index.ts b/packages/aixyz-next/index.ts index c417928d..30fb1cb3 100644 --- a/packages/aixyz-next/index.ts +++ b/packages/aixyz-next/index.ts @@ -1,3 +1,5 @@ +import { existsSync, mkdirSync, readdirSync, writeFileSync } from "fs"; +import { join } from "path"; import { AixyzApp } from "aixyz/app"; import { A2APlugin } from "aixyz/app/plugins/a2a"; import { MCPPlugin } from "aixyz/app/plugins/mcp"; @@ -157,3 +159,158 @@ export function toNextRouteHandler(app: AixyzApp): NextRouteHandlers { OPTIONS: handler, }; } + +// --------------------------------------------------------------------------- +// Next.js config integration +// --------------------------------------------------------------------------- + +/** + * Minimal Next.js config shape — avoids a hard runtime dependency on `next` for types. + * Compatible with `NextConfig` from `next`. + */ +export type NextConfig = { + webpack?: (config: WebpackConfig, options: WebpackOptions) => WebpackConfig; + transpilePackages?: string[]; + experimental?: Record; + [key: string]: unknown; +}; + +type WebpackConfig = { + resolve?: { alias?: Record; [key: string]: unknown }; + [key: string]: unknown; +}; + +type WebpackOptions = { + isServer: boolean; + dir: string; + [key: string]: unknown; +}; + +/** + * Wraps a Next.js config to automatically wire up `./app/agent.ts` and `./app/tools/*.ts` + * as A2A/MCP endpoints — zero manual imports required. + * + * On startup, `withAixyzConfig` scans your project's `app/` directory, generates a + * route handler in `.next/cache/aixyz/route.mjs`, and adds a webpack alias so that + * `@aixyz/next/route` resolves to that generated handler. + * + * If `app/api/[[...route]]/route.ts` doesn't exist yet, it is auto-created with a single + * re-export line — the only file you ever need to commit. + * + * @example + * ```ts + * // next.config.ts + * import { withAixyzConfig } from "@aixyz/next"; + * export default withAixyzConfig({}); + * ``` + * + * That's it — no route file imports, no plugin wiring, nothing else. + */ +export function withAixyzConfig(nextConfig: NextConfig = {}): NextConfig { + const cwd = process.cwd(); + + // Auto-create the catch-all route file if the project doesn't have one yet. + ensureRouteFile(cwd); + + // Generate the handler code into .next/cache/ and obtain its absolute path. + const generatedRoute = generateAixyzRoute(cwd); + + return { + ...nextConfig, + webpack(config: WebpackConfig, options: WebpackOptions) { + // Redirect @aixyz/next/route to the auto-generated handler so that + // `export ... from "@aixyz/next/route"` picks up the scanned agent/tools. + config.resolve = { + ...config.resolve, + alias: { + ...(config.resolve?.alias ?? {}), + "@aixyz/next/route": generatedRoute, + }, + }; + + if (typeof nextConfig.webpack === "function") { + return nextConfig.webpack(config, options); + } + return config; + }, + }; +} + +/** + * Auto-create `app/api/[[...route]]/route.ts` if it doesn't exist. + * The created file is a stable one-liner that is safe to commit. + * + * @internal + */ +export function ensureRouteFile(cwd: string): void { + const routeDir = join(cwd, "app", "api", "[[...route]]"); + const routeFile = join(routeDir, "route.ts"); + + if (existsSync(routeFile)) return; + + mkdirSync(routeDir, { recursive: true }); + writeFileSync( + routeFile, + `// Auto-generated by @aixyz/next — safe to commit\nexport { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } from "@aixyz/next/route";\n`, + ); +} + +/** + * Scan `/app/agent.ts` and `/app/tools/*.ts`, generate a self-contained + * route handler module in `.next/cache/aixyz/route.mjs`, and return its absolute path. + * + * The generated `.mjs` file is plain JavaScript ESM that imports directly from the + * discovered source files; Next.js / webpack applies TypeScript compilation to those + * imports as normal. + * + * Files starting with `_` in `tools/` are excluded (matching the CLI convention). + * + * @internal + */ +export function generateAixyzRoute(cwd: string): string { + const appDir = join(cwd, "app"); + const toolsDir = join(appDir, "tools"); + + // Locate app/agent.{ts,js} + const agentFile = ["agent.ts", "agent.js"].map((f) => join(appDir, f)).find(existsSync); + + const cacheDir = join(cwd, ".next", "cache", "aixyz"); + mkdirSync(cacheDir, { recursive: true }); + + const lines: string[] = [ + `// Auto-generated by @aixyz/next — DO NOT EDIT`, + `import { createNextHandler } from "@aixyz/next";`, + ]; + const opts: string[] = []; + + if (agentFile) { + lines.push(`import * as __agent from ${JSON.stringify(agentFile)};`); + opts.push(` agent: __agent,`); + } + + const toolEntries: string[] = []; + if (existsSync(toolsDir)) { + for (const file of readdirSync(toolsDir).sort()) { + // Mirror CLI convention: skip _ prefixed files, include .ts and .js + if (/^[^_].*\.[jt]s$/.test(file)) { + const name = file.replace(/\.[jt]s$/, ""); + const id = `__tool_${name.replace(/[^a-zA-Z0-9]/g, "_")}`; + lines.push(`import * as ${id} from ${JSON.stringify(join(toolsDir, file))};`); + toolEntries.push(` { name: ${JSON.stringify(name)}, exports: ${id} },`); + } + } + } + + if (toolEntries.length > 0) { + opts.push(` tools: [\n${toolEntries.join("\n")}\n ],`); + } + + lines.push(``); + lines.push(`export const { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } = createNextHandler({`); + lines.push(...opts); + lines.push(`});`); + + const routePath = join(cacheDir, "route.mjs"); + writeFileSync(routePath, lines.join("\n"), "utf-8"); + return routePath; +} diff --git a/packages/aixyz-next/package.json b/packages/aixyz-next/package.json index 40d410e2..d4151c2e 100644 --- a/packages/aixyz-next/package.json +++ b/packages/aixyz-next/package.json @@ -19,7 +19,8 @@ "author": "AgentlyHQ", "type": "module", "exports": { - ".": "./index.ts" + ".": "./index.ts", + "./route": "./route.ts" }, "files": [ "**/*.ts", diff --git a/packages/aixyz-next/route.ts b/packages/aixyz-next/route.ts new file mode 100644 index 00000000..85e4f8a9 --- /dev/null +++ b/packages/aixyz-next/route.ts @@ -0,0 +1,25 @@ +/** + * Re-exports from `@aixyz/next/route` — replaced at build time by the webpack alias + * that `withAixyzConfig` sets up in `next.config.ts`. + * + * At runtime this file throws a helpful error if `withAixyzConfig` was not used. + * + * @see {@link withAixyzConfig} + */ +import type { NextRouteHandlers } from "./index"; + +const missingConfig = (): never => { + throw new Error( + "[@aixyz/next] @aixyz/next/route requires withAixyzConfig in your next.config.ts.\n" + + "Add: import { withAixyzConfig } from '@aixyz/next'; export default withAixyzConfig({});", + ); +}; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const GET: NextRouteHandlers["GET"] = missingConfig as any; +export const POST: NextRouteHandlers["POST"] = missingConfig as any; +export const PUT: NextRouteHandlers["PUT"] = missingConfig as any; +export const DELETE: NextRouteHandlers["DELETE"] = missingConfig as any; +export const PATCH: NextRouteHandlers["PATCH"] = missingConfig as any; +export const HEAD: NextRouteHandlers["HEAD"] = missingConfig as any; +export const OPTIONS: NextRouteHandlers["OPTIONS"] = missingConfig as any; From 402b4b3680c0494b4fe23edb72891f68f320c7c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:26:52 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20move=20ensureRouteFile/generateAixyz?= =?UTF-8?q?Route=20into=20webpack=20fn,=20rename=20cwd=E2=86=92dir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: fuxingloh <4266087+fuxingloh@users.noreply.github.com> --- app/api/[[...route]]/route.ts | 2 -- packages/aixyz-next/index.test.ts | 42 +++++++++++++++++++++++++------ packages/aixyz-next/index.ts | 30 +++++++++++----------- 3 files changed, 50 insertions(+), 24 deletions(-) delete mode 100644 app/api/[[...route]]/route.ts diff --git a/app/api/[[...route]]/route.ts b/app/api/[[...route]]/route.ts deleted file mode 100644 index 2bf3bf7a..00000000 --- a/app/api/[[...route]]/route.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Auto-generated by @aixyz/next — safe to commit -export { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } from "@aixyz/next/route"; diff --git a/packages/aixyz-next/index.test.ts b/packages/aixyz-next/index.test.ts index e11aff8c..a2b59bbf 100644 --- a/packages/aixyz-next/index.test.ts +++ b/packages/aixyz-next/index.test.ts @@ -72,25 +72,34 @@ function makeTempProject(): string { describe("withAixyzConfig", () => { test("returns a config object with a webpack function", () => { - const dir = makeTempProject(); - mkdirSync(join(dir, "app"), { recursive: true }); - - // withAixyzConfig runs against process.cwd(); test the returned config shape. const config = withAixyzConfig({}); expect(typeof config).toBe("object"); expect(typeof config.webpack).toBe("function"); - rmSync(dir, { recursive: true, force: true }); }); - test("webpack function adds @aixyz/next/route alias", () => { + test("webpack function creates route file and adds @aixyz/next/route alias", () => { + const dir = makeTempProject(); + mkdirSync(join(dir, "app"), { recursive: true }); + const config = withAixyzConfig({}); const webpackConfig: any = { resolve: { alias: {} } }; - const result = (config.webpack as any)(webpackConfig, { isServer: true, dir: process.cwd() }); + const result = (config.webpack as any)(webpackConfig, { isServer: true, dir }); + + // Alias points to a generated .mjs file expect(typeof result.resolve.alias["@aixyz/next/route"]).toBe("string"); expect(result.resolve.alias["@aixyz/next/route"]).toContain("route.mjs"); + + // Route file was auto-created + const routeFile = join(dir, "app", "api", "[[...route]]", "route.ts"); + expect(existsSync(routeFile)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); }); test("webpack function calls through user-provided webpack", () => { + const dir = makeTempProject(); + mkdirSync(join(dir, "app"), { recursive: true }); + let called = false; const userWebpack = (cfg: any) => { called = true; @@ -98,8 +107,10 @@ describe("withAixyzConfig", () => { }; const config = withAixyzConfig({ webpack: userWebpack }); const webpackConfig: any = { resolve: { alias: {} } }; - (config.webpack as any)(webpackConfig, { isServer: true, dir: process.cwd() }); + (config.webpack as any)(webpackConfig, { isServer: true, dir }); + expect(called).toBe(true); + rmSync(dir, { recursive: true, force: true }); }); test("preserves user config keys", () => { @@ -107,6 +118,21 @@ describe("withAixyzConfig", () => { expect((config as any).reactStrictMode).toBe(true); expect((config as any).swcMinify).toBe(true); }); + + test("webpack function does not create duplicate aliases when called twice", () => { + const dir = makeTempProject(); + mkdirSync(join(dir, "app"), { recursive: true }); + + const config = withAixyzConfig({}); + const webpackConfig: any = { resolve: { alias: {} } }; + + // Simulate webpack calling the function twice (server + client compile) + (config.webpack as any)(webpackConfig, { isServer: true, dir }); + const result = (config.webpack as any)(webpackConfig, { isServer: false, dir }); + + expect(typeof result.resolve.alias["@aixyz/next/route"]).toBe("string"); + rmSync(dir, { recursive: true, force: true }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/aixyz-next/index.ts b/packages/aixyz-next/index.ts index 30fb1cb3..cdfbf548 100644 --- a/packages/aixyz-next/index.ts +++ b/packages/aixyz-next/index.ts @@ -207,17 +207,19 @@ type WebpackOptions = { * That's it — no route file imports, no plugin wiring, nothing else. */ export function withAixyzConfig(nextConfig: NextConfig = {}): NextConfig { - const cwd = process.cwd(); - - // Auto-create the catch-all route file if the project doesn't have one yet. - ensureRouteFile(cwd); - - // Generate the handler code into .next/cache/ and obtain its absolute path. - const generatedRoute = generateAixyzRoute(cwd); - return { ...nextConfig, webpack(config: WebpackConfig, options: WebpackOptions) { + const { dir } = options; + + // Auto-create the catch-all route file if the project doesn't have one yet. + // Uses options.dir (the real Next.js project root) rather than process.cwd() + // so that this works correctly regardless of where the process is started from. + ensureRouteFile(dir); + + // Generate the handler code into .next/cache/ and obtain its absolute path. + const generatedRoute = generateAixyzRoute(dir); + // Redirect @aixyz/next/route to the auto-generated handler so that // `export ... from "@aixyz/next/route"` picks up the scanned agent/tools. config.resolve = { @@ -242,8 +244,8 @@ export function withAixyzConfig(nextConfig: NextConfig = {}): NextConfig { * * @internal */ -export function ensureRouteFile(cwd: string): void { - const routeDir = join(cwd, "app", "api", "[[...route]]"); +export function ensureRouteFile(dir: string): void { + const routeDir = join(dir, "app", "api", "[[...route]]"); const routeFile = join(routeDir, "route.ts"); if (existsSync(routeFile)) return; @@ -256,7 +258,7 @@ export function ensureRouteFile(cwd: string): void { } /** - * Scan `/app/agent.ts` and `/app/tools/*.ts`, generate a self-contained + * Scan `/app/agent.ts` and `/app/tools/*.ts`, generate a self-contained * route handler module in `.next/cache/aixyz/route.mjs`, and return its absolute path. * * The generated `.mjs` file is plain JavaScript ESM that imports directly from the @@ -267,14 +269,14 @@ export function ensureRouteFile(cwd: string): void { * * @internal */ -export function generateAixyzRoute(cwd: string): string { - const appDir = join(cwd, "app"); +export function generateAixyzRoute(dir: string): string { + const appDir = join(dir, "app"); const toolsDir = join(appDir, "tools"); // Locate app/agent.{ts,js} const agentFile = ["agent.ts", "agent.js"].map((f) => join(appDir, f)).find(existsSync); - const cacheDir = join(cwd, ".next", "cache", "aixyz"); + const cacheDir = join(dir, ".next", "cache", "aixyz"); mkdirSync(cacheDir, { recursive: true }); const lines: string[] = [