@worktango/ai-assistant 0.0.22 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worktango/ai-assistant",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "types": "./types.d.ts",
5
5
  "files": [
6
6
  "dist",
@@ -1,41 +1,208 @@
1
+ import http from "http";
2
+
1
3
  import { Express } from "express";
2
4
 
3
5
  import { setupAiAssistantRoutes } from "./express";
4
6
 
7
+ jest.mock("http", () => ({
8
+ request: jest.fn().mockImplementation((options, callback) => {
9
+ const mockProxyRes = {
10
+ statusCode: 200,
11
+ headers: { "content-type": "application/json" },
12
+ pipe: jest.fn(),
13
+ };
14
+ callback(mockProxyRes);
15
+ return {
16
+ on: jest.fn(),
17
+ end: jest.fn(),
18
+ };
19
+ }),
20
+ }));
21
+
5
22
  describe("setupAiAssistantRoutes", () => {
6
23
  let mockApp: jest.Mocked<Express>;
7
24
  let mockPost: jest.Mock;
8
- let mockSend: jest.Mock;
25
+ let mockGetBearerToken: jest.Mock;
26
+ let mockReq: any;
27
+ let mockRes: any;
28
+ let handlerPromise: Promise<void>;
29
+
30
+ const mockLogger = {
31
+ log: jest.fn(),
32
+ error: jest.fn(),
33
+ debug: jest.fn(),
34
+ };
9
35
 
10
36
  beforeEach(() => {
11
- mockSend = jest.fn();
37
+ mockReq = {
38
+ url: "http://localhost/ai-assistant",
39
+ method: "POST",
40
+ headers: { "content-type": "application/json" },
41
+ readable: false,
42
+ };
43
+ mockRes = {
44
+ statusCode: 200,
45
+ setHeader: jest.fn(),
46
+ status: jest.fn().mockReturnThis(),
47
+ send: jest.fn(),
48
+ };
12
49
  mockPost = jest.fn().mockImplementation((path, handler) => {
13
- // Store the handler so we can call it in our tests
14
- handler({}, { send: mockSend });
50
+ handlerPromise = handler(mockReq, mockRes);
15
51
  });
52
+ mockGetBearerToken = jest.fn().mockResolvedValue("mock-token");
16
53
 
17
54
  mockApp = {
18
55
  post: mockPost,
56
+ all: mockPost,
19
57
  } as unknown as jest.Mocked<Express>;
58
+
59
+ (http.request as jest.Mock).mockClear();
20
60
  });
21
61
 
22
- it("sets up the default route correctly", () => {
23
- setupAiAssistantRoutes(mockApp);
62
+ it("sets up the default route correctly", async () => {
63
+ setupAiAssistantRoutes({
64
+ app: mockApp,
65
+ getBearerToken: mockGetBearerToken,
66
+ targetHost: "localhost",
67
+ logger: mockLogger,
68
+ });
24
69
 
25
70
  expect(mockPost).toHaveBeenCalledWith(
26
71
  "/ai-assistant",
27
72
  expect.any(Function)
28
73
  );
29
- expect(mockSend).toHaveBeenCalledWith("Hello world!");
74
+
75
+ await handlerPromise;
76
+
77
+ expect(http.request).toHaveBeenCalledWith(
78
+ expect.objectContaining({
79
+ hostname: "localhost",
80
+ path: "/ai-assistant",
81
+ headers: expect.objectContaining({
82
+ Authorization: "Bearer mock-token",
83
+ }),
84
+ }),
85
+ expect.any(Function)
86
+ );
30
87
  });
31
88
 
32
- it("sets up a custom route when provided", () => {
33
- setupAiAssistantRoutes(mockApp, { route: "/custom-route" });
89
+ it("sets up a custom route when provided", async () => {
90
+ mockReq.url = "http://localhost/custom-route";
91
+
92
+ setupAiAssistantRoutes({
93
+ app: mockApp,
94
+ getBearerToken: mockGetBearerToken,
95
+ targetHost: "localhost",
96
+ route: "/custom-route",
97
+ logger: mockLogger,
98
+ });
34
99
 
35
100
  expect(mockPost).toHaveBeenCalledWith(
36
101
  "/custom-route",
37
102
  expect.any(Function)
38
103
  );
39
- expect(mockSend).toHaveBeenCalledWith("Hello world!");
104
+
105
+ await handlerPromise;
106
+
107
+ expect(http.request).toHaveBeenCalledWith(
108
+ expect.objectContaining({
109
+ hostname: "localhost",
110
+ path: "/custom-route",
111
+ headers: expect.objectContaining({
112
+ Authorization: "Bearer mock-token",
113
+ }),
114
+ }),
115
+ expect.any(Function)
116
+ );
117
+ });
118
+
119
+ it("handles errors when getting the bearer token", async () => {
120
+ mockGetBearerToken.mockRejectedValueOnce(
121
+ new Error("Error getting bearer token")
122
+ );
123
+
124
+ setupAiAssistantRoutes({
125
+ app: mockApp,
126
+ getBearerToken: mockGetBearerToken,
127
+ targetHost: "localhost",
128
+ logger: mockLogger,
129
+ });
130
+
131
+ await handlerPromise;
132
+
133
+ expect(mockRes.status).toHaveBeenCalledWith(500);
134
+ expect(mockRes.send).toHaveBeenCalledWith("Error getting bearer token");
135
+ });
136
+
137
+ it("handles proxy errors correctly", async () => {
138
+ const mockError = new Error("Proxy error");
139
+ (http.request as jest.Mock).mockImplementationOnce(() => ({
140
+ on: jest.fn().mockImplementation((event, handler) => {
141
+ if (event === "error") {
142
+ handler(mockError);
143
+ }
144
+ }),
145
+ end: jest.fn(),
146
+ }));
147
+
148
+ setupAiAssistantRoutes({
149
+ app: mockApp,
150
+ getBearerToken: mockGetBearerToken,
151
+ targetHost: "localhost",
152
+ logger: mockLogger,
153
+ });
154
+
155
+ await handlerPromise;
156
+
157
+ expect(mockRes.status).toHaveBeenCalledWith(500);
158
+ expect(mockRes.send).toHaveBeenCalledWith("Proxy Error: Proxy error");
159
+ });
160
+
161
+ it("handles readable request bodies", async () => {
162
+ mockReq.readable = true;
163
+ mockReq.pipe = jest.fn();
164
+
165
+ setupAiAssistantRoutes({
166
+ app: mockApp,
167
+ getBearerToken: mockGetBearerToken,
168
+ targetHost: "localhost",
169
+ logger: mockLogger,
170
+ });
171
+
172
+ await handlerPromise;
173
+
174
+ expect(mockReq.pipe).toHaveBeenCalled();
175
+ });
176
+
177
+ it("handles response headers and status code correctly", async () => {
178
+ const mockProxyRes = {
179
+ statusCode: undefined,
180
+ headers: {
181
+ "content-type": undefined,
182
+ "x-custom-header": "value",
183
+ },
184
+ pipe: jest.fn(),
185
+ };
186
+
187
+ (http.request as jest.Mock).mockImplementationOnce((options, callback) => {
188
+ callback(mockProxyRes);
189
+ return {
190
+ on: jest.fn(),
191
+ end: jest.fn(),
192
+ };
193
+ });
194
+
195
+ setupAiAssistantRoutes({
196
+ app: mockApp,
197
+ getBearerToken: mockGetBearerToken,
198
+ targetHost: "localhost",
199
+ logger: mockLogger,
200
+ });
201
+
202
+ await handlerPromise;
203
+
204
+ expect(mockRes.statusCode).toBe(500);
205
+ expect(mockRes.setHeader).toHaveBeenCalledWith("x-custom-header", "value");
206
+ expect(mockRes.setHeader).toHaveBeenCalledWith("content-type", "");
40
207
  });
41
208
  });
@@ -1,17 +1,98 @@
1
- import { Express } from "express";
1
+ import http from "http";
2
+
3
+ import { Express, RequestHandler } from "express";
4
+
5
+ import { coalesce } from "@kazoohr/helpers";
2
6
 
3
7
  /**
4
8
  * Sets up the AI assistant routes on the given Express app.
5
9
  */
6
- export function setupAiAssistantRoutes(
7
- app: Express,
8
- options?: {
9
- route?: string;
10
- }
11
- ) {
12
- const route = options?.route ?? "/ai-assistant";
13
- app.post(route, (req, res) => {
14
- // TODO: Generate the server-to-server request token to the KW AI assistant and proxy the response back to the client.
15
- res.send("Hello world!");
16
- });
10
+ export function setupAiAssistantRoutes(args: {
11
+ app: Express;
12
+ /**
13
+ * A function that returns the bearer token for the AI assistant to use for
14
+ * back-channel request proxying. This should be a JWT token that can be
15
+ * decoded on the `kazoo-web` application to authenticate the request.
16
+ */
17
+ getBearerToken: () => Promise<string>;
18
+ /**
19
+ * The host of the application to proxy requests to.
20
+ */
21
+ targetHost: string;
22
+ /**
23
+ * The port of the application to proxy requests to. Defaults to 80.
24
+ */
25
+ targetPort?: number;
26
+ /**
27
+ * The route path to the local service's AI assistant. Defaults to
28
+ * "/ai-assistant".
29
+ */
30
+ route?: string;
31
+ /**
32
+ * The logger to use for logging.
33
+ */
34
+ logger?: Pick<typeof console, "log" | "error" | "debug">;
35
+ /**
36
+ * Middleware functions to run before the proxy request.
37
+ */
38
+ preflightMiddlewares?: RequestHandler[];
39
+ }) {
40
+ const logger = coalesce(args.logger, console);
41
+ const routePath = args.route ?? "/ai-assistant";
42
+
43
+ args.app.all(
44
+ routePath,
45
+ ...coalesce(args.preflightMiddlewares, []),
46
+ async (req, res) => {
47
+ const bearerToken = await args.getBearerToken().catch((error) => {
48
+ logger.error("Error getting bearer token:", error);
49
+ res.status(500).send("Error getting bearer token");
50
+ return null;
51
+ });
52
+
53
+ if (!bearerToken) {
54
+ return;
55
+ }
56
+
57
+ const parsedUrl = new URL(req.url);
58
+
59
+ const options = {
60
+ hostname: args.targetHost,
61
+ port: args.targetPort,
62
+ path: parsedUrl.pathname,
63
+ method: req.method,
64
+ headers: { ...req.headers },
65
+ };
66
+
67
+ options.headers["Authorization"] = `Bearer ${bearerToken}`;
68
+ delete options.headers.host;
69
+
70
+ logger.debug("Proxying request:", {
71
+ method: req.method,
72
+ headers: options.headers,
73
+ proxyOptions: options,
74
+ });
75
+
76
+ const proxyReq = http.request(options, (proxyRes) => {
77
+ res.statusCode = proxyRes.statusCode || 500;
78
+
79
+ Object.keys(proxyRes.headers).forEach((key) => {
80
+ res.setHeader(key, proxyRes.headers[key] || "");
81
+ });
82
+
83
+ proxyRes.pipe(res);
84
+ });
85
+
86
+ proxyReq.on("error", (error) => {
87
+ logger.error("Proxy request error:", error);
88
+ res.status(500).send("Proxy Error: " + error.message);
89
+ });
90
+
91
+ if (req.readable) {
92
+ req.pipe(proxyReq);
93
+ } else {
94
+ proxyReq.end();
95
+ }
96
+ }
97
+ );
17
98
  }
package/types.d.ts CHANGED
@@ -32,3 +32,38 @@ declare module "@worktango/ai-assistant/react" {
32
32
  tools?: ToolDeclaration[];
33
33
  }): JSX.Element;
34
34
  }
35
+
36
+ declare module "@worktango/ai-assistant/express" {
37
+ export function setupAiAssistantRoutes(args: {
38
+ app: {
39
+ all: (path: string, ...args: any[]) => void;
40
+ };
41
+ /**
42
+ * A function that returns the bearer token for the AI assistant to use for
43
+ * back-channel request proxying. This should be a JWT token that can be
44
+ * decoded on the `kazoo-web` application to authenticate the request.
45
+ */
46
+ getBearerToken: () => Promise<string>;
47
+ /**
48
+ * The host of the application to proxy requests to.
49
+ */
50
+ targetHost: string;
51
+ /**
52
+ * The port of the application to proxy requests to. Defaults to 80.
53
+ */
54
+ targetPort?: number;
55
+ /**
56
+ * The route path to the local service's AI assistant. Defaults to
57
+ * "/ai-assistant".
58
+ */
59
+ route?: string;
60
+ /**
61
+ * The logger to use for logging.
62
+ */
63
+ logger?: Pick<typeof console, "log" | "error" | "debug">;
64
+ /**
65
+ * Middleware functions to run before the proxy request.
66
+ */
67
+ preflightMiddlewares?: RequestHandler[];
68
+ }): void;
69
+ }