@flowget/ai-chat 0.1.0

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.
@@ -0,0 +1,91 @@
1
+ import { ResolvedConfig, AuthorRequest } from '@flowget/ai';
2
+ export { AuthorRequest, ResolvedConfig } from '@flowget/ai';
3
+
4
+ /**
5
+ * `@flowget/ai-chat/server` — the BFF helper (React-free).
6
+ *
7
+ * Wraps `@flowget/ai`'s `authorStream()` and forwards it to the browser as SSE
8
+ * (one `data:`-framed event per line: `text-delta`* then a terminal
9
+ * `proposal | message`, or `error`), matching what `@flowget/ai-chat/react`'s
10
+ * default transport reads. The host owns the engine config (its node catalog,
11
+ * LLM adapter, guardrail, toolsets) and passes it in — this helper only does
12
+ * the streaming plumbing.
13
+ *
14
+ * ⚠️ **Unauthenticated by design.** This helper performs NO authn/authz and NO
15
+ * rate-limiting. The host MUST place its own auth + rate-limiting in front of
16
+ * the route, and — for any actor-scoped data access — inject a *server-derived*
17
+ * actor via `options.buildRequest` (never trust the client-supplied `actor`).
18
+ * See the README "Security" section.
19
+ *
20
+ * ```ts
21
+ * // e.g. in a Bun / Node / edge route
22
+ * import { createChatStreamResponse } from "@flowget/ai-chat/server";
23
+ * import { finalizeConfig, resolveCatalog, openaiAdapter } from "@flowget/ai";
24
+ *
25
+ * const catalog = await resolveCatalog({ registryDir });
26
+ * const config = finalizeConfig({ adapter: openaiAdapter({ apiKey }), catalog }, catalog);
27
+ *
28
+ * export async function POST(req: Request): Promise<Response> {
29
+ * // your auth middleware has already run:
30
+ * const session = await getSession(req);
31
+ * if (!session) return new Response("Unauthorized", { status: 401 });
32
+ * return createChatStreamResponse(req, config, {
33
+ * // override the untrusted client actor with a server-derived one:
34
+ * buildRequest: (parsed) => ({ ...parsed, actor: session.actor }),
35
+ * });
36
+ * }
37
+ * ```
38
+ */
39
+
40
+ /** A resolved config, or a per-request factory (e.g. to pick an adapter). */
41
+ type ChatConfig = ResolvedConfig | ((request: AuthorRequest) => ResolvedConfig | Promise<ResolvedConfig>);
42
+ /** Bounds applied to the request body before authoring. */
43
+ type ChatRequestLimits = {
44
+ /** Max accepted request-body size, in bytes. Default 1 MiB. */
45
+ maxBodyBytes?: number;
46
+ /** Max accepted `command` length, in characters. Default 8000. */
47
+ maxCommandLength?: number;
48
+ };
49
+ /**
50
+ * Parse a chat POST body into an {@link AuthorRequest}. Returns `null` when the
51
+ * body isn't a JSON object with a non-empty, length-bounded `command`, is over
52
+ * the size cap, or carries a malformed `currentGraph` (a non-object, or an
53
+ * array — `typeof [] === "object"`, so arrays are rejected explicitly).
54
+ *
55
+ * `actor` / `context` are passed through **as-is and untrusted** — a server
56
+ * that gates on the actor must override it (see `createChatStreamResponse`'s
57
+ * `buildRequest`) and/or verify it with a `@flowget/ai` authorizer.
58
+ */
59
+ declare function parseChatRequest(req: Request, limits?: ChatRequestLimits): Promise<AuthorRequest | null>;
60
+ /** Options for {@link createChatStreamResponse}. */
61
+ type CreateChatStreamOptions = ChatRequestLimits & {
62
+ /**
63
+ * Rebuild the parsed request before authoring — the seam for injecting a
64
+ * **server-derived** actor / context (from the host's session/JWT) over the
65
+ * untrusted client-supplied values. Return the request to author.
66
+ */
67
+ buildRequest?: (parsed: AuthorRequest, req: Request) => AuthorRequest | Promise<AuthorRequest>;
68
+ /**
69
+ * Map a mid-stream error to a **client-safe** message. The raw error is
70
+ * always logged server-side; the client only ever sees this string. Defaults
71
+ * to a generic `"Authoring failed"`.
72
+ */
73
+ onError?: (err: unknown) => string;
74
+ };
75
+ /**
76
+ * Stream `authorStream` for a **pre-built** {@link AuthorRequest} as an SSE
77
+ * `Response`. The lower-level primitive: use this when you build/validate the
78
+ * request yourself; otherwise prefer {@link createChatStreamResponse}.
79
+ */
80
+ declare function streamAuthorSSE(request: AuthorRequest, config: ChatConfig, options?: {
81
+ signal?: AbortSignal;
82
+ onError?: (err: unknown) => string;
83
+ }): Response;
84
+ /**
85
+ * Parse + (optionally) rebuild the request, then stream `authorStream` as SSE.
86
+ * `config` is the host's `@flowget/ai` `ResolvedConfig` (or a per-request
87
+ * factory). See {@link CreateChatStreamOptions} for the injection + error seams.
88
+ */
89
+ declare function createChatStreamResponse(req: Request, config: ChatConfig, options?: CreateChatStreamOptions): Promise<Response>;
90
+
91
+ export { type ChatConfig, type ChatRequestLimits, type CreateChatStreamOptions, createChatStreamResponse, parseChatRequest, streamAuthorSSE };
@@ -0,0 +1,91 @@
1
+ import { ResolvedConfig, AuthorRequest } from '@flowget/ai';
2
+ export { AuthorRequest, ResolvedConfig } from '@flowget/ai';
3
+
4
+ /**
5
+ * `@flowget/ai-chat/server` — the BFF helper (React-free).
6
+ *
7
+ * Wraps `@flowget/ai`'s `authorStream()` and forwards it to the browser as SSE
8
+ * (one `data:`-framed event per line: `text-delta`* then a terminal
9
+ * `proposal | message`, or `error`), matching what `@flowget/ai-chat/react`'s
10
+ * default transport reads. The host owns the engine config (its node catalog,
11
+ * LLM adapter, guardrail, toolsets) and passes it in — this helper only does
12
+ * the streaming plumbing.
13
+ *
14
+ * ⚠️ **Unauthenticated by design.** This helper performs NO authn/authz and NO
15
+ * rate-limiting. The host MUST place its own auth + rate-limiting in front of
16
+ * the route, and — for any actor-scoped data access — inject a *server-derived*
17
+ * actor via `options.buildRequest` (never trust the client-supplied `actor`).
18
+ * See the README "Security" section.
19
+ *
20
+ * ```ts
21
+ * // e.g. in a Bun / Node / edge route
22
+ * import { createChatStreamResponse } from "@flowget/ai-chat/server";
23
+ * import { finalizeConfig, resolveCatalog, openaiAdapter } from "@flowget/ai";
24
+ *
25
+ * const catalog = await resolveCatalog({ registryDir });
26
+ * const config = finalizeConfig({ adapter: openaiAdapter({ apiKey }), catalog }, catalog);
27
+ *
28
+ * export async function POST(req: Request): Promise<Response> {
29
+ * // your auth middleware has already run:
30
+ * const session = await getSession(req);
31
+ * if (!session) return new Response("Unauthorized", { status: 401 });
32
+ * return createChatStreamResponse(req, config, {
33
+ * // override the untrusted client actor with a server-derived one:
34
+ * buildRequest: (parsed) => ({ ...parsed, actor: session.actor }),
35
+ * });
36
+ * }
37
+ * ```
38
+ */
39
+
40
+ /** A resolved config, or a per-request factory (e.g. to pick an adapter). */
41
+ type ChatConfig = ResolvedConfig | ((request: AuthorRequest) => ResolvedConfig | Promise<ResolvedConfig>);
42
+ /** Bounds applied to the request body before authoring. */
43
+ type ChatRequestLimits = {
44
+ /** Max accepted request-body size, in bytes. Default 1 MiB. */
45
+ maxBodyBytes?: number;
46
+ /** Max accepted `command` length, in characters. Default 8000. */
47
+ maxCommandLength?: number;
48
+ };
49
+ /**
50
+ * Parse a chat POST body into an {@link AuthorRequest}. Returns `null` when the
51
+ * body isn't a JSON object with a non-empty, length-bounded `command`, is over
52
+ * the size cap, or carries a malformed `currentGraph` (a non-object, or an
53
+ * array — `typeof [] === "object"`, so arrays are rejected explicitly).
54
+ *
55
+ * `actor` / `context` are passed through **as-is and untrusted** — a server
56
+ * that gates on the actor must override it (see `createChatStreamResponse`'s
57
+ * `buildRequest`) and/or verify it with a `@flowget/ai` authorizer.
58
+ */
59
+ declare function parseChatRequest(req: Request, limits?: ChatRequestLimits): Promise<AuthorRequest | null>;
60
+ /** Options for {@link createChatStreamResponse}. */
61
+ type CreateChatStreamOptions = ChatRequestLimits & {
62
+ /**
63
+ * Rebuild the parsed request before authoring — the seam for injecting a
64
+ * **server-derived** actor / context (from the host's session/JWT) over the
65
+ * untrusted client-supplied values. Return the request to author.
66
+ */
67
+ buildRequest?: (parsed: AuthorRequest, req: Request) => AuthorRequest | Promise<AuthorRequest>;
68
+ /**
69
+ * Map a mid-stream error to a **client-safe** message. The raw error is
70
+ * always logged server-side; the client only ever sees this string. Defaults
71
+ * to a generic `"Authoring failed"`.
72
+ */
73
+ onError?: (err: unknown) => string;
74
+ };
75
+ /**
76
+ * Stream `authorStream` for a **pre-built** {@link AuthorRequest} as an SSE
77
+ * `Response`. The lower-level primitive: use this when you build/validate the
78
+ * request yourself; otherwise prefer {@link createChatStreamResponse}.
79
+ */
80
+ declare function streamAuthorSSE(request: AuthorRequest, config: ChatConfig, options?: {
81
+ signal?: AbortSignal;
82
+ onError?: (err: unknown) => string;
83
+ }): Response;
84
+ /**
85
+ * Parse + (optionally) rebuild the request, then stream `authorStream` as SSE.
86
+ * `config` is the host's `@flowget/ai` `ResolvedConfig` (or a per-request
87
+ * factory). See {@link CreateChatStreamOptions} for the injection + error seams.
88
+ */
89
+ declare function createChatStreamResponse(req: Request, config: ChatConfig, options?: CreateChatStreamOptions): Promise<Response>;
90
+
91
+ export { type ChatConfig, type ChatRequestLimits, type CreateChatStreamOptions, createChatStreamResponse, parseChatRequest, streamAuthorSSE };
package/dist/server.js ADDED
@@ -0,0 +1,114 @@
1
+ import { authorStream } from '@flowget/ai';
2
+
3
+ // src/server/index.ts
4
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5
+ var DEFAULT_MAX_COMMAND_LENGTH = 8e3;
6
+ async function parseChatRequest(req, limits = {}) {
7
+ const maxBodyBytes = limits.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
8
+ const maxCommandLength = limits.maxCommandLength ?? DEFAULT_MAX_COMMAND_LENGTH;
9
+ const declared = Number(req.headers.get("content-length"));
10
+ if (Number.isFinite(declared) && declared > maxBodyBytes) return null;
11
+ let text;
12
+ try {
13
+ text = await req.text();
14
+ } catch {
15
+ return null;
16
+ }
17
+ if (text.length > maxBodyBytes) return null;
18
+ let body;
19
+ try {
20
+ body = JSON.parse(text);
21
+ } catch {
22
+ return null;
23
+ }
24
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
25
+ return null;
26
+ }
27
+ const record = body;
28
+ const command = typeof record.command === "string" ? record.command : "";
29
+ if (command.trim() === "" || command.length > maxCommandLength) return null;
30
+ const currentGraph = record.currentGraph !== null && typeof record.currentGraph === "object" && !Array.isArray(record.currentGraph) ? record.currentGraph : void 0;
31
+ const actor = record.actor !== null && typeof record.actor === "object" && !Array.isArray(record.actor) ? record.actor : void 0;
32
+ return {
33
+ command,
34
+ ...currentGraph !== void 0 ? { currentGraph } : {},
35
+ ...actor !== void 0 ? { actor } : {},
36
+ ...record.context !== void 0 ? { context: record.context } : {}
37
+ };
38
+ }
39
+ function sseFrame(event) {
40
+ return `data: ${JSON.stringify(event)}
41
+
42
+ `;
43
+ }
44
+ var SSE_HEADERS = {
45
+ "content-type": "text/event-stream",
46
+ "cache-control": "no-cache, no-transform",
47
+ // Disable proxy buffering (nginx) so events flush as they're produced.
48
+ "x-accel-buffering": "no"
49
+ };
50
+ function streamAuthorSSE(request, config, options = {}) {
51
+ const { signal, onError } = options;
52
+ const stream = new ReadableStream({
53
+ async start(controller) {
54
+ const encoder = new TextEncoder();
55
+ let closed = false;
56
+ const send = (event) => {
57
+ if (closed) return;
58
+ try {
59
+ controller.enqueue(encoder.encode(sseFrame(event)));
60
+ } catch {
61
+ closed = true;
62
+ }
63
+ };
64
+ try {
65
+ const resolved = typeof config === "function" ? await config(request) : config;
66
+ for await (const event of authorStream(resolved, request, {
67
+ ...signal ? { signal } : {}
68
+ })) {
69
+ send(event);
70
+ }
71
+ } catch (err) {
72
+ console.error("[@flowget/ai-chat] authoring stream failed:", err);
73
+ send({
74
+ kind: "error",
75
+ error: onError ? onError(err) : "Authoring failed"
76
+ });
77
+ } finally {
78
+ closed = true;
79
+ try {
80
+ controller.close();
81
+ } catch {
82
+ }
83
+ }
84
+ }
85
+ });
86
+ return new Response(stream, { status: 200, headers: { ...SSE_HEADERS } });
87
+ }
88
+ async function createChatStreamResponse(req, config, options = {}) {
89
+ const { buildRequest, onError, ...limits } = options;
90
+ const parsed = await parseChatRequest(req, limits);
91
+ if (parsed === null) {
92
+ const body = sseFrame({ kind: "error", error: "Invalid request" });
93
+ return new Response(body, { status: 200, headers: { ...SSE_HEADERS } });
94
+ }
95
+ let request = parsed;
96
+ if (buildRequest) {
97
+ try {
98
+ request = await buildRequest(parsed, req);
99
+ } catch (err) {
100
+ console.error("[@flowget/ai-chat] buildRequest failed:", err);
101
+ const body = sseFrame({
102
+ kind: "error",
103
+ error: onError ? onError(err) : "Authoring failed"
104
+ });
105
+ return new Response(body, { status: 200, headers: { ...SSE_HEADERS } });
106
+ }
107
+ }
108
+ return streamAuthorSSE(request, config, {
109
+ ...req.signal ? { signal: req.signal } : {},
110
+ ...onError ? { onError } : {}
111
+ });
112
+ }
113
+
114
+ export { createChatStreamResponse, parseChatRequest, streamAuthorSSE };