Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/server-chatcops-next.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions api/server.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export declare function chatcopsCloudflareHandler(config: ChatCopsServerConfig):

export declare function chatcopsMiddleware(config: ChatCopsServerConfig): (req: Request, res: Response, _next: NextFunction) => Promise<void>;

export declare function chatcopsNext(config: ChatCopsServerConfig): {
OPTIONS: (req: Request) => Promise<Response>;
POST: (req: Request) => Promise<Response>;
};

export interface ChatCopsServerConfig {
provider: ProviderConfig | AIProvider;
systemPrompt: string;
Expand Down Expand Up @@ -223,6 +228,15 @@ export declare function chatcopsMiddleware(config: ChatCopsServerConfig): (req:
export declare function chatcopsVercelHandler(config: ChatCopsServerConfig): (req: Request) => Promise<Response>;
```

## `@chatcops/server/next`

```ts
export declare function chatcopsNext(config: ChatCopsServerConfig): {
OPTIONS: (req: Request) => Promise<Response>;
POST: (req: Request) => Promise<Response>;
};
```

## `@chatcops/server/cloudflare`

```ts
Expand Down
2 changes: 1 addition & 1 deletion packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions packages/server/src/adapters/next.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { chatcopsVercelHandler } from './vercel.js';
import type { ChatCopsServerConfig } from '../config.js';

export function chatcopsNext(config: ChatCopsServerConfig) {
const handler = chatcopsVercelHandler(config);
return { POST: handler, OPTIONS: handler };
}
1 change: 1 addition & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
92 changes: 92 additions & 0 deletions packages/server/tests/adapters/next.test.ts
Original file line number Diff line number Diff line change
@@ -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]');
});
});
2 changes: 1 addition & 1 deletion scripts/check-doc-samples.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
12 changes: 4 additions & 8 deletions website/src/content/docs/examples/vercel-full.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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!,
Expand All @@ -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/).

Expand Down
19 changes: 7 additions & 12 deletions website/src/content/docs/server/fetch-runtimes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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!,
Expand All @@ -26,19 +26,14 @@ 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:
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.

```typescript
// docs-check: skip, re-exports the route file above
export { POST, OPTIONS } from '../route';
```
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.

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

Expand Down
4 changes: 3 additions & 1 deletion website/src/content/docs/server/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/server/setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ Then it sends `Authorization: Bearer <token>` 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`.

Expand Down
Loading