From 0d0ce860eb5fe39ba0a2cb886edfb34573b1ee25 Mon Sep 17 00:00:00 2001 From: Saif Ullah jafri Date: Mon, 2 Nov 2026 18:19:30 +0530 Subject: [PATCH 1/3] feat(server): add chatcopsNext() catch-all route adapter for Next.js (#574) --- .changeset/server-chatcops-next.md | 5 + api/server.api.md | 14 +++ packages/server/package.json | 4 + packages/server/src/adapters/next.ts | 21 +++++ packages/server/src/index.ts | 1 + packages/server/tests/adapters/next.test.ts | 92 +++++++++++++++++++ scripts/check-doc-samples.mjs | 2 +- .../content/docs/server/fetch-runtimes.mdx | 19 ++-- 8 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 .changeset/server-chatcops-next.md create mode 100644 packages/server/src/adapters/next.ts create mode 100644 packages/server/tests/adapters/next.test.ts diff --git a/.changeset/server-chatcops-next.md b/.changeset/server-chatcops-next.md new file mode 100644 index 00000000..7ee1fb4e --- /dev/null +++ b/.changeset/server-chatcops-next.md @@ -0,0 +1,5 @@ +--- +"@chatcops/server": minor +--- + +Add `chatcopsNext()`, a single catch-all route adapter for Next.js App Router that handles both chat and session requests. diff --git a/api/server.api.md b/api/server.api.md index ded08305..e50d61b9 100644 --- a/api/server.api.md +++ b/api/server.api.md @@ -17,6 +17,11 @@ export declare function chatcopsCloudflareHandler(config: ChatCopsServerConfig): export declare function chatcopsMiddleware(config: ChatCopsServerConfig): (req: Request, res: Response, _next: NextFunction) => Promise; +export declare function chatcopsNext(config: ChatCopsServerConfig): { + OPTIONS: (req: Request) => Promise; + POST: (req: Request) => Promise; +}; + export interface ChatCopsServerConfig { provider: ProviderConfig | AIProvider; systemPrompt: string; @@ -223,6 +228,15 @@ export declare function chatcopsMiddleware(config: ChatCopsServerConfig): (req: export declare function chatcopsVercelHandler(config: ChatCopsServerConfig): (req: Request) => Promise; ``` +## `@chatcops/server/next` + +```ts +export declare function chatcopsNext(config: ChatCopsServerConfig): { + OPTIONS: (req: Request) => Promise; + POST: (req: Request) => Promise; +}; +``` + ## `@chatcops/server/cloudflare` ```ts diff --git a/packages/server/package.json b/packages/server/package.json index b8da55f6..d89ef177 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -30,6 +30,10 @@ "types": "./dist/adapters/vercel.d.ts", "import": "./dist/adapters/vercel.js" }, + "./next": { + "types": "./dist/adapters/next.d.ts", + "import": "./dist/adapters/next.js" + }, "./cloudflare": { "types": "./dist/adapters/cloudflare.d.ts", "import": "./dist/adapters/cloudflare.js" diff --git a/packages/server/src/adapters/next.ts b/packages/server/src/adapters/next.ts new file mode 100644 index 00000000..0707e623 --- /dev/null +++ b/packages/server/src/adapters/next.ts @@ -0,0 +1,21 @@ +import { fetchClientIp } from '../client-ip.js'; +import { createChatHandler } from '../handler.js'; +import { fetchChatResponse } from '../http.js'; +import { originPolicy } from '../security/origin-policy.js'; +import type { ChatCopsServerConfig } from '../config.js'; + +export function chatcopsNext(config: ChatCopsServerConfig) { + const chat = createChatHandler(config); + const origins = originPolicy(config); + + const handler = (req: Request): Promise => { + return fetchChatResponse(chat, origins, req, { + clientIp: (request) => fetchClientIp(request, config.clientIp, chat.logger), + }); + }; + + return { + POST: handler, + OPTIONS: handler, + }; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 49043ac3..7974f333 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -14,6 +14,7 @@ export { } from './webhook.js'; export { chatcopsMiddleware, chatcopsAnalyticsHandler } from './adapters/express.js'; export { chatcopsVercelHandler } from './adapters/vercel.js'; +export { chatcopsNext } from './adapters/next.js'; export { chatcopsCloudflareHandler } from './adapters/cloudflare.js'; export { chatcopsAiSdkHandler } from './adapters/ai-sdk.js'; export { ServerAnalytics } from './analytics/server.js'; diff --git a/packages/server/tests/adapters/next.test.ts b/packages/server/tests/adapters/next.test.ts new file mode 100644 index 00000000..6a5db664 --- /dev/null +++ b/packages/server/tests/adapters/next.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { scriptedProvider, textStep } from '@chatcops/core/testing/provider'; +import { chatcopsNext } from '../../src/adapters/next.js'; + +const SECRET = 'a-session-secret-that-is-long-enough-for-chatcops'; +const SITE = 'https://acme.com'; + +describe('chatcopsNext', () => { + const provider = scriptedProvider(Array.from({ length: 10 }, () => textStep('Hello from Next.js'))); + const { POST, OPTIONS } = chatcopsNext({ + provider, + systemPrompt: 'You are a helpful assistant.', + allowedOrigins: [SITE], + sessionSecrets: [SECRET], + clientIp: () => '127.0.0.1', + }); + + it('handles OPTIONS preflight with 204 and CORS headers', async () => { + const req = new Request('http://localhost/api/chat', { + method: 'OPTIONS', + headers: { Origin: SITE }, + }); + const res = await OPTIONS(req); + expect(res.status).toBe(204); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe(SITE); + }); + + it('handles session POST to /api/chat/session and mints a session token', async () => { + const req = new Request('http://localhost/api/chat/session', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: SITE, + }, + body: JSON.stringify({}), + }); + const res = await POST(req); + expect(res.status).toBe(200); + const body = (await res.json()) as { token: string; conversationId: string; expiresAt: number }; + expect(body.token).toBeTypeOf('string'); + expect(body.conversationId).toBeTypeOf('string'); + expect(body.expiresAt).toBeTypeOf('number'); + }); + + it('returns 401 when chat POST to /api/chat is missing a session token', async () => { + const req = new Request('http://localhost/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: SITE, + }, + body: JSON.stringify({ protocol: 1, conversationId: 'c1', message: 'Hello' }), + }); + const res = await POST(req); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe('session_required'); + }); + + it('handles chat POST to /api/chat with a valid session token and streams the response', async () => { + const sessionReq = new Request('http://localhost/api/chat/session', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: SITE, + }, + body: JSON.stringify({}), + }); + const sessionRes = await POST(sessionReq); + const sessionData = (await sessionRes.json()) as { token: string; conversationId: string }; + + const chatReq = new Request('http://localhost/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: SITE, + Authorization: `Bearer ${sessionData.token}`, + }, + body: JSON.stringify({ + protocol: 1, + conversationId: sessionData.conversationId, + message: 'Hello', + }), + }); + const chatRes = await POST(chatReq); + expect(chatRes.status).toBe(200); + expect(chatRes.headers.get('Content-Type')).toContain('text/event-stream'); + const text = await chatRes.text(); + expect(text).toContain('Hello'); + expect(text).toContain('[DONE]'); + }); +}); diff --git a/scripts/check-doc-samples.mjs b/scripts/check-doc-samples.mjs index e2641c71..b8897386 100644 --- a/scripts/check-doc-samples.mjs +++ b/scripts/check-doc-samples.mjs @@ -16,7 +16,7 @@ const blocks = []; // which type-checking can't see. Flags a handler built from an object literal that // has neither, or a full `ChatCopsServerConfig` that has neither, unless it spreads // another config in. -const handlerCall = /\b(chatcopsMiddleware|chatcopsVercelHandler|chatcopsCloudflareHandler|chatcopsAiSdkHandler|createChatHandler)\(\s*\{|:\s*(ChatCopsServerConfig)\s*=\s*\{/g; +const handlerCall = /\b(chatcopsMiddleware|chatcopsVercelHandler|chatcopsNext|chatcopsCloudflareHandler|chatcopsAiSdkHandler|createChatHandler)\(\s*\{|:\s*(ChatCopsServerConfig)\s*=\s*\{/g; function missingSessionConfig(code) { const lines = []; for (const match of code.matchAll(handlerCall)) { diff --git a/website/src/content/docs/server/fetch-runtimes.mdx b/website/src/content/docs/server/fetch-runtimes.mdx index bf0940bf..cb8a54ea 100644 --- a/website/src/content/docs/server/fetch-runtimes.mdx +++ b/website/src/content/docs/server/fetch-runtimes.mdx @@ -12,12 +12,12 @@ Two things apply to every example on this page: ## Next.js (App Router) -Create `app/api/chat/route.ts`: +Create `app/api/chat/[[...path]]/route.ts`: ```typescript -import { chatcopsVercelHandler } from '@chatcops/server'; +import { chatcopsNext } from '@chatcops/server/next'; -const handler = chatcopsVercelHandler({ +export const { POST, OPTIONS } = chatcopsNext({ provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY!, @@ -26,19 +26,12 @@ const handler = chatcopsVercelHandler({ allowedOrigins: ['*'], sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET!], }); - -export const POST = handler; -export const OPTIONS = handler; +export const runtime = 'nodejs'; ``` -The widget gets its session token from `/api/chat/session`, so add `app/api/chat/session/route.ts` with the same handler: - -```typescript -// docs-check: skip, re-exports the route file above -export { POST, OPTIONS } from '../route'; -``` +The catch-all route answers both the chat endpoint at `/api/chat` and the session endpoint at `/api/chat/session`. The widget's `data-api-url` is then `/api/chat` on the same site, or the full URL from another origin. -The widget's `data-api-url` is then `/api/chat` on the same site, or the full URL from another origin. +> **Older setups:** Older code used two route files with `chatcopsVercelHandler`: `app/api/chat/route.ts` and `app/api/chat/session/route.ts` (re-exporting `POST` and `OPTIONS` from the first). The single catch-all route replaces both. ## Hono From 25468116a93f15143868d3c0b147315846e3bb37 Mon Sep 17 00:00:00 2001 From: Saif Ullah jafri Date: Fri, 6 Nov 2026 21:11:44 +0530 Subject: [PATCH 2/3] refactor(server): reuse chatcopsVercelHandler in chatcopsNext and update Next.js docs --- packages/server/src/adapters/next.ts | 20 +++---------------- .../src/content/docs/examples/vercel-full.mdx | 12 ++++------- .../content/docs/server/fetch-runtimes.mdx | 2 ++ website/src/content/docs/server/setup.mdx | 2 +- 4 files changed, 10 insertions(+), 26 deletions(-) diff --git a/packages/server/src/adapters/next.ts b/packages/server/src/adapters/next.ts index 0707e623..7b6b6aeb 100644 --- a/packages/server/src/adapters/next.ts +++ b/packages/server/src/adapters/next.ts @@ -1,21 +1,7 @@ -import { fetchClientIp } from '../client-ip.js'; -import { createChatHandler } from '../handler.js'; -import { fetchChatResponse } from '../http.js'; -import { originPolicy } from '../security/origin-policy.js'; +import { chatcopsVercelHandler } from './vercel.js'; import type { ChatCopsServerConfig } from '../config.js'; export function chatcopsNext(config: ChatCopsServerConfig) { - const chat = createChatHandler(config); - const origins = originPolicy(config); - - const handler = (req: Request): Promise => { - return fetchChatResponse(chat, origins, req, { - clientIp: (request) => fetchClientIp(request, config.clientIp, chat.logger), - }); - }; - - return { - POST: handler, - OPTIONS: handler, - }; + const handler = chatcopsVercelHandler(config); + return { POST: handler, OPTIONS: handler }; } diff --git a/website/src/content/docs/examples/vercel-full.mdx b/website/src/content/docs/examples/vercel-full.mdx index 8336dd97..dbb647e8 100644 --- a/website/src/content/docs/examples/vercel-full.mdx +++ b/website/src/content/docs/examples/vercel-full.mdx @@ -57,14 +57,14 @@ Add `api/chat/session.ts` with `export { default, config } from '../chat';`, so ## Next.js Integration -If using Next.js, place the file at `app/api/chat/route.ts`: +For Next.js (App Router), create a single catch-all route at `app/api/chat/[[...path]]/route.ts` with `chatcopsNext()` (see [Fetch runtimes](/server/fetch-runtimes/#nextjs-app-router)): ```typescript -import { chatcopsVercelHandler } from '@chatcops/server'; +import { chatcopsNext } from '@chatcops/server/next'; export const runtime = 'edge'; -const handler = chatcopsVercelHandler({ +export const { POST, OPTIONS } = chatcopsNext({ provider: { type: 'openai', apiKey: process.env.OPENAI_API_KEY!, @@ -73,13 +73,9 @@ const handler = chatcopsVercelHandler({ allowedOrigins: ['*'], sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET!], }); - -export const POST = handler; -// The handler answers the CORS preflight, so a widget on another origin needs OPTIONS too. -export const OPTIONS = handler; ``` -Then `app/api/chat/session/route.ts`, for the session token: `export { POST, OPTIONS } from '../route';`. +The single catch-all route answers both the chat requests at `/api/chat` and session requests at `/api/chat/session`, so no second route file is needed. More runtimes (Hono, Bun, Deno) are in [Next.js, Hono, Bun and Deno](/server/fetch-runtimes/). diff --git a/website/src/content/docs/server/fetch-runtimes.mdx b/website/src/content/docs/server/fetch-runtimes.mdx index cb8a54ea..7c48d872 100644 --- a/website/src/content/docs/server/fetch-runtimes.mdx +++ b/website/src/content/docs/server/fetch-runtimes.mdx @@ -31,6 +31,8 @@ export const runtime = 'nodejs'; The catch-all route answers both the chat endpoint at `/api/chat` and the session endpoint at `/api/chat/session`. The widget's `data-api-url` is then `/api/chat` on the same site, or the full URL from another origin. +The double brackets in `[[...path]]` (an optional catch-all route) are required: single brackets (`[...path]`) only match paths with at least one segment after `/api/chat`, so `/api/chat` itself would return a 404. + > **Older setups:** Older code used two route files with `chatcopsVercelHandler`: `app/api/chat/route.ts` and `app/api/chat/session/route.ts` (re-exporting `POST` and `OPTIONS` from the first). The single catch-all route replaces both. ## Hono diff --git a/website/src/content/docs/server/setup.mdx b/website/src/content/docs/server/setup.mdx index b0a09cd4..c36a11c7 100644 --- a/website/src/content/docs/server/setup.mdx +++ b/website/src/content/docs/server/setup.mdx @@ -227,7 +227,7 @@ Then it sends `Authorization: Bearer ` with each chat request. Tokens las The widget does all of this on its own from 0.6. When a chat request gets a 401, it asks `/session` for a token, keeps it in `localStorage` and sends the request again. When the server answers with a new conversation id, the widget moves to it. -With Express, mount the middleware with `app.use('/api/chat', ...)` so `/api/chat/session` reaches it. On Vercel or Next.js, export the same handler from a second route file at `app/api/chat/session/route.ts`. A Cloudflare Worker gets every path already. +With Express, mount the middleware with `app.use('/api/chat', ...)` so `/api/chat/session` reaches it. In Next.js, export `chatcopsNext` from an optional catch-all route at `app/api/chat/[[...path]]/route.ts` (see [Fetch runtimes](/server/fetch-runtimes/#nextjs-app-router)), which handles both `/api/chat` and `/api/chat/session` from a single file. On Vercel without Next.js, export the handler from a second file at `api/chat/session.ts`. A Cloudflare Worker gets every path already. Chat requests without a token get `401 session_required`, an expired token gets `401 token_expired`, and a token for another conversation gets `401 conversation_mismatch`. From d7d8fb20154bddafb4c37658436e52dca23140a7 Mon Sep 17 00:00:00 2001 From: Saif Ullah jafri Date: Fri, 6 Nov 2026 21:46:29 +0530 Subject: [PATCH 3/3] docs(server): point security doc and server README at Next.js catch-all route --- packages/server/README.md | 2 +- website/src/content/docs/server/security.mdx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/server/README.md b/packages/server/README.md index 47c336ff..7299d2f3 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -50,7 +50,7 @@ export const OPTIONS = (req: Request) => handler(req); export const config = { runtime: 'edge' }; ``` -Export the same handler from the `/session` route next to it (`app/api/chat/session/route.ts` for a chat at `/api/chat`), where the widget gets its session token. +In Next.js, use an optional catch-all route at `app/api/chat/[[...path]]/route.ts` with `chatcopsNext` (from `@chatcops/server/next`) so `/api/chat` and `/api/chat/session` reach the same handler. On Vercel without Next.js, export the same handler from a second file at `api/chat/session.ts` for the session token. ### Cloudflare Workers diff --git a/website/src/content/docs/server/security.mdx b/website/src/content/docs/server/security.mdx index 3a918db2..4394ab4c 100644 --- a/website/src/content/docs/server/security.mdx +++ b/website/src/content/docs/server/security.mdx @@ -64,8 +64,10 @@ Mount it with `app.use` so `/api/chat/session` reaches it too. ### Vercel and Next.js +In Next.js, export `chatcopsNext` from an optional catch-all route at `app/api/chat/[[...path]]/route.ts` (see [Fetch runtimes](/server/fetch-runtimes/#nextjs-app-router)), which handles both `/api/chat` and `/api/chat/session` from a single file. On Vercel without Next.js, export the handler from a second file at `api/chat/session.ts`. + ```typescript -// app/api/chat/route.ts, and the same two lines in app/api/chat/session/route.ts +// Next.js: app/api/chat/[[...path]]/route.ts. Vercel: api/chat.ts (and api/chat/session.ts) import { chatcopsVercelHandler } from '@chatcops/server'; import { redisStorage } from '@chatcops/store-redis'; import { Redis } from '@upstash/redis';