rushy-sdk 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roman Dmytriev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # rushy-sdk
2
+
3
+ Один пакет для подключения чата Rushy к продукту: серверный вход `rushy-sdk/host` и React-хук `rushy-sdk/react`. Требуется Node.js 22 или новее. React нужен только клиентскому входу.
4
+
5
+ ```bash
6
+ npm install rushy-sdk
7
+ ```
8
+
9
+ ## Хост: Next.js Route Handler
10
+
11
+ `getSessionUser` — функция вашего продукта, которая проверяет серверную сессию по запросу и возвращает пользователя или `null`. Разместите обработчик в `app/api/rushy/chat/route.ts`:
12
+
13
+ ```ts
14
+ import { createRushyChatHandler } from "rushy-sdk/host";
15
+ import { getSessionUser } from "@/lib/session";
16
+
17
+ const handler = createRushyChatHandler({
18
+ gatewayUrl: process.env.RUSHY_GATEWAY_URL!,
19
+ hostSecret: new TextEncoder().encode(process.env.RUSHY_HOST_SECRET!),
20
+ getUser: async (request) => {
21
+ const user = await getSessionUser(request);
22
+ return user ? { id: user.id, name: user.name } : null;
23
+ },
24
+ });
25
+
26
+ export const POST = handler;
27
+ ```
28
+
29
+ ## MCP-эндпоинт хоста
30
+
31
+ Разместите обработчик в `app/api/rushy/mcp/route.ts`. Сервисный токен задаётся в настройках
32
+ MCP-сервера Onyx и должен содержать не менее 32 байт. `X-Rushy-Identity` подписывает шлюз Rushy;
33
+ инструмент получает проверенное утверждение и сам проверяет права пользователя в продукте.
34
+
35
+ ```ts
36
+ import { createRushyMcpHandler, defineRushyTool } from "rushy-sdk/host";
37
+ import { z } from "zod";
38
+ import { getOrderForUser } from "@/lib/orders";
39
+
40
+ const getOrder = defineRushyTool({
41
+ name: "get_order",
42
+ description: "Получить заказ по идентификатору",
43
+ input: z.object({ orderId: z.string().min(1) }),
44
+ handler: async ({ orderId }, { identity, signal }) => {
45
+ // Проверка доступа остаётся в продукте: identity.sub — пользователь хоста.
46
+ return getOrderForUser(identity.sub, orderId, { signal });
47
+ },
48
+ });
49
+
50
+ export const POST = createRushyMcpHandler({
51
+ serviceToken: process.env.RUSHY_MCP_SERVICE_TOKEN!,
52
+ identitySecret: new TextEncoder().encode(process.env.RUSHY_IDENTITY_SECRET!),
53
+ audience: "my-host",
54
+ tools: [getOrder],
55
+ });
56
+ ```
57
+
58
+ ## Клиент: React
59
+
60
+ ```tsx
61
+ "use client";
62
+
63
+ import { useRushyChat } from "rushy-sdk/react";
64
+
65
+ export function Chat() {
66
+ const { messages, send, stop, status } = useRushyChat({ endpoint: "/api/rushy/chat" });
67
+ return (
68
+ <section>
69
+ {messages.map((message, index) => <p key={index}>{message.text}</p>)}
70
+ <button onClick={() => void send("Привет")}>Отправить</button>
71
+ {status === "streaming" && <button onClick={stop}>Остановить</button>}
72
+ </section>
73
+ );
74
+ }
75
+ ```
76
+
77
+ ## Безопасность
78
+
79
+ - Пользователь определяется только из проверенной сессии хоста в `getUser`; идентификатор из тела запроса не принимается.
80
+ - `RUSHY_HOST_SECRET` хранится только на сервере и должен содержать не менее 32 байт после кодирования.
81
+ - Адрес шлюза задавайте во внутренней сети; не публикуйте шлюз наружу.
@@ -0,0 +1,93 @@
1
+ // ../contracts/src/tokens.ts
2
+ import { z } from "zod";
3
+ var unixSeconds = z.number().int().positive();
4
+ var tokenExchangeRequestSchema = z.strictObject({
5
+ v: z.literal(1),
6
+ hostUserId: z.string().min(1).max(128),
7
+ displayName: z.string().max(200).optional(),
8
+ iat: unixSeconds,
9
+ nonce: z.string().min(16).max(128)
10
+ });
11
+ var MAX_BROWSER_TOKEN_TTL = 900;
12
+ var MAX_ASSERTION_TTL = 60;
13
+ var rushyTokenClaimsSchema = z.strictObject({
14
+ v: z.literal(1),
15
+ sub: z.string().min(1).max(128),
16
+ name: z.string().max(200).optional(),
17
+ iat: unixSeconds,
18
+ exp: unixSeconds,
19
+ jti: z.string().min(1)
20
+ }).refine((c) => c.exp > c.iat && c.exp - c.iat <= MAX_BROWSER_TOKEN_TTL, {
21
+ message: "\u0442\u043E\u043A\u0435\u043D \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0430 \u0436\u0438\u0432\u0451\u0442 \u043D\u0435 \u0434\u043E\u043B\u044C\u0448\u0435 15 \u043C\u0438\u043D\u0443\u0442"
22
+ });
23
+ var identityAssertionSchema = z.strictObject({
24
+ v: z.literal(1),
25
+ iss: z.literal("rushy-gateway"),
26
+ aud: z.string().min(1),
27
+ sub: z.string().min(1).max(128),
28
+ iat: unixSeconds,
29
+ exp: unixSeconds,
30
+ cid: z.string().min(1)
31
+ }).refine((a) => a.exp > a.iat && a.exp - a.iat <= MAX_ASSERTION_TTL, {
32
+ message: "identity assertion \u0436\u0438\u0432\u0451\u0442 \u043D\u0435 \u0434\u043E\u043B\u044C\u0448\u0435 60 \u0441\u0435\u043A\u0443\u043D\u0434"
33
+ });
34
+
35
+ // ../contracts/src/ui-event.ts
36
+ import { z as z2 } from "zod";
37
+ var citationSchema = z2.object({
38
+ index: z2.number().int().nonnegative(),
39
+ documentId: z2.string().min(1),
40
+ title: z2.string(),
41
+ // Только http(s): ссылка пойдёт в href интерфейса хоста, javascript:/data: — это XSS.
42
+ url: z2.url({ protocol: /^https?$/ }).optional()
43
+ });
44
+ var errorCodeSchema = z2.enum([
45
+ "upstream_unavailable",
46
+ "upstream_error",
47
+ "rate_limited",
48
+ "unauthorized",
49
+ "forbidden",
50
+ "stream_interrupted"
51
+ ]);
52
+ var uiEventSchema = z2.discriminatedUnion("type", [
53
+ z2.object({ type: z2.literal("assistant.delta"), text: z2.string() }),
54
+ z2.object({ type: z2.literal("reasoning.delta"), text: z2.string() }),
55
+ z2.object({ type: z2.literal("tool.started"), id: z2.string(), name: z2.string() }),
56
+ z2.object({ type: z2.literal("tool.finished"), id: z2.string(), ok: z2.boolean() }),
57
+ z2.object({ type: z2.literal("citation"), citation: citationSchema }),
58
+ z2.object({ type: z2.literal("message.completed"), messageId: z2.string() }),
59
+ z2.object({ type: z2.literal("error"), code: errorCodeSchema, retryable: z2.boolean() })
60
+ ]);
61
+
62
+ // ../contracts/src/channel-envelope.ts
63
+ import { z as z3 } from "zod";
64
+ var attachmentRefSchema = z3.strictObject({
65
+ kind: z3.enum(["file", "image", "voice"]),
66
+ // Непрозрачная ссылка транспорта (Telegram file_id). URL с токеном бота сюда не кладётся.
67
+ transportRef: z3.string().min(1),
68
+ mimeType: z3.string().optional(),
69
+ sizeBytes: z3.number().int().nonnegative().optional()
70
+ });
71
+ var channelEnvelopeSchema = z3.strictObject({
72
+ schemaVersion: z3.literal(1),
73
+ requestId: z3.string().min(1),
74
+ channel: z3.enum(["telegram", "web", "embedded"]),
75
+ actor: z3.strictObject({ externalUserId: z3.string().min(1) }),
76
+ conversation: z3.strictObject({
77
+ externalConversationId: z3.string().min(1),
78
+ threadId: z3.string().optional(),
79
+ messageId: z3.string().optional(),
80
+ replyToMessageId: z3.string().optional()
81
+ }),
82
+ content: z3.strictObject({
83
+ text: z3.string().optional(),
84
+ attachments: z3.array(attachmentRefSchema).optional()
85
+ }),
86
+ receivedAt: z3.iso.datetime()
87
+ });
88
+
89
+ export {
90
+ uiEventSchema,
91
+ tokenExchangeRequestSchema,
92
+ identityAssertionSchema
93
+ };
package/dist/host.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { z } from 'zod';
2
+
3
+ declare const identityAssertionSchema: z.ZodObject<{
4
+ v: z.ZodLiteral<1>;
5
+ iss: z.ZodLiteral<"rushy-gateway">;
6
+ aud: z.ZodString;
7
+ sub: z.ZodString;
8
+ iat: z.ZodNumber;
9
+ exp: z.ZodNumber;
10
+ cid: z.ZodString;
11
+ }, z.core.$strict>;
12
+ type IdentityAssertion = z.infer<typeof identityAssertionSchema>;
13
+
14
+ type IdentityErrorCode = "invalid_signature" | "expired" | "malformed" | "wrong_audience" | "weak_secret";
15
+ declare class IdentityError extends Error {
16
+ readonly code: IdentityErrorCode;
17
+ constructor(code: IdentityErrorCode);
18
+ }
19
+ declare function verifyIdentityAssertion(jws: string, secret: Uint8Array, expectedAud: string, now?: number): Promise<IdentityAssertion>;
20
+
21
+ interface RushyChatUser {
22
+ id: string;
23
+ name?: string;
24
+ }
25
+ interface RushyChatHandlerOptions {
26
+ gatewayUrl: string;
27
+ hostSecret: Uint8Array;
28
+ getUser: (req: Request) => Promise<RushyChatUser | null>;
29
+ fetch?: typeof fetch;
30
+ }
31
+ declare function createRushyChatHandler(opts: RushyChatHandlerOptions): (req: Request) => Promise<Response>;
32
+
33
+ interface RushyToolContext {
34
+ identity: IdentityAssertion;
35
+ signal: AbortSignal;
36
+ }
37
+ interface RushyTool<S extends z.ZodType = z.ZodType> {
38
+ name: string;
39
+ description: string;
40
+ input: S;
41
+ handler(input: z.infer<S>, ctx: RushyToolContext): Promise<unknown>;
42
+ }
43
+ declare function defineRushyTool<S extends z.ZodType>(tool: RushyTool<S>): RushyTool<S>;
44
+ interface RushyMcpHandlerOptions {
45
+ serviceToken: string;
46
+ identitySecret: Uint8Array;
47
+ audience: string;
48
+ tools: RushyTool[];
49
+ serverInfo?: {
50
+ name: string;
51
+ version: string;
52
+ };
53
+ now?: () => number;
54
+ }
55
+ declare function createRushyMcpHandler(opts: RushyMcpHandlerOptions): (req: Request) => Promise<Response>;
56
+
57
+ export { type IdentityAssertion, IdentityError, type RushyChatHandlerOptions, type RushyChatUser, type RushyMcpHandlerOptions, type RushyTool, type RushyToolContext, createRushyChatHandler, createRushyMcpHandler, defineRushyTool, identityAssertionSchema, verifyIdentityAssertion };
package/dist/host.js ADDED
@@ -0,0 +1,288 @@
1
+ import {
2
+ identityAssertionSchema,
3
+ tokenExchangeRequestSchema
4
+ } from "./chunk-NOF7HANN.js";
5
+
6
+ // ../identity/src/jws.ts
7
+ import { randomBytes } from "crypto";
8
+ import { decodeProtectedHeader, errors, jwtVerify, SignJWT } from "jose";
9
+ var MIN_SECRET_BYTES = 32;
10
+ function assertStrongSecret(secret) {
11
+ if (secret.byteLength < MIN_SECRET_BYTES) throw new IdentityError("weak_secret");
12
+ }
13
+ var IdentityError = class extends Error {
14
+ constructor(code) {
15
+ super(code);
16
+ this.code = code;
17
+ this.name = "IdentityError";
18
+ }
19
+ code;
20
+ };
21
+ function unixNow(now) {
22
+ return now ?? Math.floor(Date.now() / 1e3);
23
+ }
24
+ async function verifiedPayload(jws, secret, now) {
25
+ assertStrongSecret(secret);
26
+ try {
27
+ if (decodeProtectedHeader(jws).alg !== "HS256") {
28
+ throw new IdentityError("invalid_signature");
29
+ }
30
+ const { payload } = await jwtVerify(jws, secret, {
31
+ algorithms: ["HS256"],
32
+ currentDate: new Date(now * 1e3)
33
+ });
34
+ return payload;
35
+ } catch (error) {
36
+ if (error instanceof IdentityError) throw error;
37
+ if (error instanceof errors.JWTExpired) throw new IdentityError("expired");
38
+ if (error instanceof errors.JWSInvalid || error instanceof errors.JWTInvalid) {
39
+ throw new IdentityError("malformed");
40
+ }
41
+ throw new IdentityError("invalid_signature");
42
+ }
43
+ }
44
+ async function signHostRequest(claims, secret, now) {
45
+ assertStrongSecret(secret);
46
+ const payload = tokenExchangeRequestSchema.safeParse({
47
+ ...claims,
48
+ v: 1,
49
+ iat: unixNow(now),
50
+ nonce: randomBytes(16).toString("base64url")
51
+ });
52
+ if (!payload.success) throw new IdentityError("malformed");
53
+ return new SignJWT(payload.data).setProtectedHeader({ alg: "HS256" }).sign(secret);
54
+ }
55
+ async function verifyIdentityAssertion(jws, secret, expectedAud, now) {
56
+ const parsed = identityAssertionSchema.safeParse(
57
+ await verifiedPayload(jws, secret, unixNow(now))
58
+ );
59
+ if (!parsed.success) throw new IdentityError("malformed");
60
+ if (parsed.data.aud !== expectedAud) throw new IdentityError("wrong_audience");
61
+ return parsed.data;
62
+ }
63
+
64
+ // ../host-sdk/src/handler.ts
65
+ function createRushyChatHandler(opts) {
66
+ const fetchFn = opts.fetch ?? globalThis.fetch;
67
+ const gatewayEndpoint = opts.gatewayUrl.endsWith("/v1/chat") ? opts.gatewayUrl : `${opts.gatewayUrl.replace(/\/+$/, "")}/v1/chat`;
68
+ return async (req) => {
69
+ const user = await opts.getUser(req);
70
+ if (!user) {
71
+ return new Response(JSON.stringify({ error: "unauthorized" }), {
72
+ status: 401,
73
+ headers: { "Content-Type": "application/json" }
74
+ });
75
+ }
76
+ const token = await signHostRequest(
77
+ {
78
+ hostUserId: user.id,
79
+ ...user.name ? { displayName: user.name } : {}
80
+ },
81
+ opts.hostSecret
82
+ );
83
+ let clientPayload = {};
84
+ try {
85
+ const parsed = await req.json();
86
+ if (typeof parsed === "object" && parsed !== null) {
87
+ clientPayload = parsed;
88
+ }
89
+ } catch {
90
+ }
91
+ const forwardPayload = {};
92
+ if ("message" in clientPayload) {
93
+ forwardPayload.message = clientPayload.message;
94
+ }
95
+ if ("conversationId" in clientPayload && typeof clientPayload.conversationId === "string") {
96
+ forwardPayload.conversationId = clientPayload.conversationId;
97
+ }
98
+ const gatewayResponse = await fetchFn(gatewayEndpoint, {
99
+ method: "POST",
100
+ headers: {
101
+ "Content-Type": "application/json",
102
+ "X-Rushy-Request": token
103
+ },
104
+ body: JSON.stringify(forwardPayload),
105
+ // Пользователь закрыл чат — шлюз и Onyx прекращают генерацию, а не жгут токены LLM впустую.
106
+ signal: req.signal
107
+ });
108
+ const headers = new Headers();
109
+ const contentType = gatewayResponse.headers.get("content-type");
110
+ if (contentType) {
111
+ headers.set("content-type", contentType);
112
+ } else {
113
+ headers.set("content-type", "application/x-ndjson");
114
+ }
115
+ const correlationId = gatewayResponse.headers.get("x-correlation-id");
116
+ if (correlationId) headers.set("x-correlation-id", correlationId);
117
+ return new Response(gatewayResponse.body, {
118
+ status: gatewayResponse.status,
119
+ statusText: gatewayResponse.statusText,
120
+ headers
121
+ });
122
+ };
123
+ }
124
+
125
+ // ../host-sdk/src/mcp.ts
126
+ import { createHash, timingSafeEqual } from "crypto";
127
+ import { z } from "zod";
128
+ var MAX_BODY_BYTES = 64 * 1024;
129
+ var TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
130
+ var SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"];
131
+ var DEFAULT_VERSION = "2025-06-18";
132
+ var RESPONSE_HEADERS = {
133
+ "Content-Type": "application/json",
134
+ "Cache-Control": "no-store"
135
+ };
136
+ function isZodObject(value) {
137
+ const def = value?._zod?.def;
138
+ return def?.type === "object";
139
+ }
140
+ function defineRushyTool(tool) {
141
+ if (!TOOL_NAME.test(tool.name)) {
142
+ throw new Error("invalid_tool_name");
143
+ }
144
+ if (!isZodObject(tool.input)) {
145
+ throw new Error("invalid_tool_input");
146
+ }
147
+ return tool;
148
+ }
149
+ function jsonResponse(body, status = 200) {
150
+ return new Response(JSON.stringify(body), { status, headers: RESPONSE_HEADERS });
151
+ }
152
+ function rpcResult(id, result) {
153
+ return jsonResponse({ jsonrpc: "2.0", id, result });
154
+ }
155
+ function rpcError(id, code, message) {
156
+ return jsonResponse({ jsonrpc: "2.0", id, error: { code, message } });
157
+ }
158
+ function toolError(id, text) {
159
+ return rpcResult(id, { isError: true, content: [{ type: "text", text }] });
160
+ }
161
+ function isPlainObject(value) {
162
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
163
+ const prototype = Object.getPrototypeOf(value);
164
+ return prototype === Object.prototype || prototype === null;
165
+ }
166
+ async function readBody(req) {
167
+ const reader = req.body.getReader();
168
+ const chunks = [];
169
+ let byteLength = 0;
170
+ for (; ; ) {
171
+ const { done, value } = await reader.read();
172
+ if (done) break;
173
+ byteLength += value.byteLength;
174
+ if (byteLength > MAX_BODY_BYTES) {
175
+ await reader.cancel().catch(() => void 0);
176
+ return null;
177
+ }
178
+ chunks.push(value);
179
+ }
180
+ const bytes = new Uint8Array(byteLength);
181
+ let offset = 0;
182
+ for (const chunk of chunks) {
183
+ bytes.set(chunk, offset);
184
+ offset += chunk.byteLength;
185
+ }
186
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
187
+ }
188
+ function createRushyMcpHandler(opts) {
189
+ if (new TextEncoder().encode(opts.serviceToken).byteLength < 32) {
190
+ throw new Error("weak_service_token");
191
+ }
192
+ const expectedAuthorization = createHash("sha256").update(`Bearer ${opts.serviceToken}`).digest();
193
+ const tools = new Map(opts.tools.map((tool) => [defineRushyTool(tool).name, tool]));
194
+ if (tools.size !== opts.tools.length) throw new Error("duplicate_tool_name");
195
+ const schemas = opts.tools.map((tool) => {
196
+ const { $schema: _schema, ...inputSchema } = z.toJSONSchema(tool.input);
197
+ return { name: tool.name, description: tool.description, inputSchema };
198
+ });
199
+ const serverInfo = opts.serverInfo ?? { name: "rushy-host", version: "0.2.0" };
200
+ return async (req) => {
201
+ if (req.method !== "POST") {
202
+ return new Response(null, {
203
+ status: 405,
204
+ headers: { ...RESPONSE_HEADERS, Allow: "POST" }
205
+ });
206
+ }
207
+ const suppliedAuthorization = createHash("sha256").update(req.headers.get("Authorization") ?? "").digest();
208
+ if (!timingSafeEqual(suppliedAuthorization, expectedAuthorization)) {
209
+ return jsonResponse({ error: "unauthorized" }, 401);
210
+ }
211
+ let body;
212
+ try {
213
+ body = await readBody(req);
214
+ } catch {
215
+ return rpcError(null, -32700, "Parse error");
216
+ }
217
+ if (body === null) return jsonResponse({ error: "payload_too_large" }, 413);
218
+ let message;
219
+ try {
220
+ message = JSON.parse(body);
221
+ } catch {
222
+ return rpcError(null, -32700, "Parse error");
223
+ }
224
+ if (!isPlainObject(message) || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
225
+ return rpcError(null, -32600, "Invalid Request");
226
+ }
227
+ const id = message.id;
228
+ if (id === void 0) {
229
+ return new Response(null, { status: 202, headers: RESPONSE_HEADERS });
230
+ }
231
+ if (id !== null && typeof id !== "string" && (typeof id !== "number" || !Number.isFinite(id))) {
232
+ return rpcError(null, -32600, "Invalid Request");
233
+ }
234
+ if (message.method === "initialize") {
235
+ const params2 = isPlainObject(message.params) ? message.params : {};
236
+ const requestedVersion = params2.protocolVersion;
237
+ const protocolVersion = typeof requestedVersion === "string" && SUPPORTED_VERSIONS.includes(requestedVersion) ? requestedVersion : DEFAULT_VERSION;
238
+ return rpcResult(id, {
239
+ protocolVersion,
240
+ capabilities: { tools: { listChanged: false } },
241
+ serverInfo
242
+ });
243
+ }
244
+ if (message.method === "ping") return rpcResult(id, {});
245
+ if (message.method === "tools/list") return rpcResult(id, { tools: schemas });
246
+ if (message.method !== "tools/call") return rpcError(id, -32601, "Method not found");
247
+ const params = isPlainObject(message.params) ? message.params : {};
248
+ const tool = typeof params.name === "string" ? tools.get(params.name) : void 0;
249
+ if (!tool) return rpcError(id, -32602, "Invalid params");
250
+ let identity;
251
+ try {
252
+ identity = await verifyIdentityAssertion(
253
+ req.headers.get("X-Rushy-Identity") ?? "",
254
+ opts.identitySecret,
255
+ opts.audience,
256
+ opts.now?.()
257
+ );
258
+ } catch {
259
+ return toolError(id, "unauthorized");
260
+ }
261
+ let parsed;
262
+ try {
263
+ parsed = tool.input.safeParse(params.arguments);
264
+ } catch {
265
+ return toolError(id, "invalid_arguments");
266
+ }
267
+ if (!parsed.success) return toolError(id, "invalid_arguments");
268
+ try {
269
+ const value = await tool.handler(parsed.data, { identity, signal: req.signal });
270
+ const serialized = JSON.stringify(value);
271
+ const text = serialized ?? "null";
272
+ return rpcResult(id, {
273
+ content: [{ type: "text", text }],
274
+ ...isPlainObject(value) ? { structuredContent: value } : {}
275
+ });
276
+ } catch {
277
+ return toolError(id, "tool_failed");
278
+ }
279
+ };
280
+ }
281
+ export {
282
+ IdentityError,
283
+ createRushyChatHandler,
284
+ createRushyMcpHandler,
285
+ defineRushyTool,
286
+ identityAssertionSchema,
287
+ verifyIdentityAssertion
288
+ };
@@ -0,0 +1,43 @@
1
+ import { z } from 'zod';
2
+
3
+ declare const citationSchema: z.ZodObject<{
4
+ index: z.ZodNumber;
5
+ documentId: z.ZodString;
6
+ title: z.ZodString;
7
+ url: z.ZodOptional<z.ZodURL>;
8
+ }, z.core.$strip>;
9
+ type Citation = z.infer<typeof citationSchema>;
10
+ declare const errorCodeSchema: z.ZodEnum<{
11
+ upstream_unavailable: "upstream_unavailable";
12
+ upstream_error: "upstream_error";
13
+ rate_limited: "rate_limited";
14
+ unauthorized: "unauthorized";
15
+ forbidden: "forbidden";
16
+ stream_interrupted: "stream_interrupted";
17
+ }>;
18
+ type ErrorCode = z.infer<typeof errorCodeSchema>;
19
+
20
+ interface ChatMessage {
21
+ role: "user" | "assistant";
22
+ text: string;
23
+ citations: Citation[];
24
+ tools: {
25
+ id: string;
26
+ name: string;
27
+ ok?: boolean;
28
+ }[];
29
+ }
30
+ type ChatStatus = "idle" | "streaming" | "error";
31
+ interface UseRushyChatOptions {
32
+ endpoint: string;
33
+ }
34
+ interface UseRushyChatResult {
35
+ messages: ChatMessage[];
36
+ send: (text: string) => Promise<void>;
37
+ stop: () => void;
38
+ status: ChatStatus;
39
+ error?: ErrorCode;
40
+ }
41
+ declare function useRushyChat(options: UseRushyChatOptions): UseRushyChatResult;
42
+
43
+ export { type ChatMessage, type ChatStatus, type UseRushyChatOptions, type UseRushyChatResult, useRushyChat };
package/dist/react.js ADDED
@@ -0,0 +1,220 @@
1
+ import {
2
+ uiEventSchema
3
+ } from "./chunk-NOF7HANN.js";
4
+
5
+ // ../react/src/use-rushy-chat.ts
6
+ import { useCallback, useRef, useState } from "react";
7
+
8
+ // ../react/src/citation-markers.ts
9
+ var COMPLETE_MARKER = /\[\[(\d+)\]\]\([^)]*\)/g;
10
+ var TRAILING_PARTIAL = /\[\[\d*(?:\]\]?(?:\([^)]*)?)?$/;
11
+ function normalizeCitationMarkers(text) {
12
+ return text.replace(COMPLETE_MARKER, "[$1]").replace(TRAILING_PARTIAL, "");
13
+ }
14
+
15
+ // ../react/src/use-rushy-chat.ts
16
+ function useRushyChat(options) {
17
+ const [messages, setMessages] = useState([]);
18
+ const [status, setStatus] = useState("idle");
19
+ const [error, setError] = useState(void 0);
20
+ const conversationIdRef = useRef(null);
21
+ const abortControllerRef = useRef(null);
22
+ const stop = useCallback(() => {
23
+ if (abortControllerRef.current) {
24
+ abortControllerRef.current.abort();
25
+ abortControllerRef.current = null;
26
+ }
27
+ setStatus("idle");
28
+ }, []);
29
+ const send = useCallback(
30
+ async (text) => {
31
+ if (!text.trim()) {
32
+ return;
33
+ }
34
+ setError(void 0);
35
+ setStatus("streaming");
36
+ const userMessage = {
37
+ role: "user",
38
+ text,
39
+ citations: [],
40
+ tools: []
41
+ };
42
+ setMessages((prev) => [...prev, userMessage]);
43
+ const controller = new AbortController();
44
+ abortControllerRef.current = controller;
45
+ try {
46
+ const payload = {
47
+ message: text
48
+ };
49
+ if (conversationIdRef.current) {
50
+ payload.conversationId = conversationIdRef.current;
51
+ }
52
+ const response = await fetch(options.endpoint, {
53
+ method: "POST",
54
+ headers: {
55
+ "Content-Type": "application/json"
56
+ },
57
+ body: JSON.stringify(payload),
58
+ signal: controller.signal
59
+ });
60
+ if (!response.ok) {
61
+ if (response.status === 401) {
62
+ setError("unauthorized");
63
+ } else if (response.status === 403) {
64
+ setError("forbidden");
65
+ } else if (response.status === 429) {
66
+ setError("rate_limited");
67
+ } else if (response.status >= 500) {
68
+ setError("upstream_unavailable");
69
+ } else {
70
+ setError("upstream_error");
71
+ }
72
+ setStatus("error");
73
+ return;
74
+ }
75
+ if (!response.body) {
76
+ setError("upstream_unavailable");
77
+ setStatus("error");
78
+ return;
79
+ }
80
+ const assistantMessage = {
81
+ role: "assistant",
82
+ text: "",
83
+ citations: [],
84
+ tools: []
85
+ };
86
+ setMessages((prev) => [...prev, assistantMessage]);
87
+ const updateAssistant = () => {
88
+ setMessages((prev) => {
89
+ if (prev.length === 0) return prev;
90
+ const updated = [...prev];
91
+ updated[updated.length - 1] = {
92
+ role: "assistant",
93
+ // Наружу — текст без служебной разметки Onyx; сырой копится в assistantMessage.
94
+ text: normalizeCitationMarkers(assistantMessage.text),
95
+ citations: [...assistantMessage.citations],
96
+ tools: assistantMessage.tools.map((t) => ({ ...t }))
97
+ };
98
+ return updated;
99
+ });
100
+ };
101
+ const reader = response.body.getReader();
102
+ const abortHandler = () => {
103
+ try {
104
+ void reader.cancel();
105
+ } catch {
106
+ }
107
+ };
108
+ controller.signal.addEventListener("abort", abortHandler, { once: true });
109
+ const decoder = new TextDecoder();
110
+ let buffer = "";
111
+ let isFirstLine = true;
112
+ const processLine = (line) => {
113
+ const trimmed = line.trim();
114
+ if (!trimmed) return true;
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(trimmed);
118
+ } catch {
119
+ return true;
120
+ }
121
+ if (isFirstLine) {
122
+ isFirstLine = false;
123
+ if (typeof parsed === "object" && parsed !== null && "type" in parsed && parsed.type === "conversation" && "id" in parsed && typeof parsed.id === "string") {
124
+ conversationIdRef.current = parsed.id;
125
+ return true;
126
+ }
127
+ }
128
+ const eventResult = uiEventSchema.safeParse(parsed);
129
+ if (!eventResult.success) {
130
+ return true;
131
+ }
132
+ const event = eventResult.data;
133
+ switch (event.type) {
134
+ case "assistant.delta": {
135
+ assistantMessage.text += event.text;
136
+ updateAssistant();
137
+ break;
138
+ }
139
+ case "citation": {
140
+ assistantMessage.citations.push(event.citation);
141
+ updateAssistant();
142
+ break;
143
+ }
144
+ case "tool.started": {
145
+ assistantMessage.tools.push({ id: event.id, name: event.name });
146
+ updateAssistant();
147
+ break;
148
+ }
149
+ case "tool.finished": {
150
+ const existing = assistantMessage.tools.find((t) => t.id === event.id);
151
+ if (existing) {
152
+ existing.ok = event.ok;
153
+ } else {
154
+ assistantMessage.tools.push({ id: event.id, name: "", ok: event.ok });
155
+ }
156
+ updateAssistant();
157
+ break;
158
+ }
159
+ case "error": {
160
+ setError(event.code);
161
+ setStatus("error");
162
+ return false;
163
+ }
164
+ case "message.completed":
165
+ case "reasoning.delta":
166
+ break;
167
+ }
168
+ return true;
169
+ };
170
+ let chunk = await reader.read();
171
+ while (!chunk.done) {
172
+ buffer += decoder.decode(chunk.value, { stream: true });
173
+ const lines = buffer.split("\n");
174
+ buffer = lines.pop() ?? "";
175
+ for (const line of lines) {
176
+ const continueReading = processLine(line);
177
+ if (!continueReading) {
178
+ return;
179
+ }
180
+ }
181
+ chunk = await reader.read();
182
+ }
183
+ controller.signal.removeEventListener("abort", abortHandler);
184
+ if (controller.signal.aborted) {
185
+ setStatus("idle");
186
+ return;
187
+ }
188
+ if (buffer.trim()) {
189
+ const continueReading = processLine(buffer);
190
+ if (!continueReading) {
191
+ return;
192
+ }
193
+ }
194
+ setStatus("idle");
195
+ } catch {
196
+ if (controller.signal.aborted) {
197
+ setStatus("idle");
198
+ return;
199
+ }
200
+ setError("upstream_unavailable");
201
+ setStatus("error");
202
+ } finally {
203
+ if (abortControllerRef.current === controller) {
204
+ abortControllerRef.current = null;
205
+ }
206
+ }
207
+ },
208
+ [options.endpoint]
209
+ );
210
+ return {
211
+ messages,
212
+ send,
213
+ stop,
214
+ status,
215
+ error
216
+ };
217
+ }
218
+ export {
219
+ useRushyChat
220
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "rushy-sdk",
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "files": [
8
+ "dist",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "engines": {
13
+ "node": ">=22"
14
+ },
15
+ "exports": {
16
+ "./host": {
17
+ "types": "./dist/host.d.ts",
18
+ "import": "./dist/host.js"
19
+ },
20
+ "./react": {
21
+ "types": "./dist/react.d.ts",
22
+ "import": "./dist/react.js"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "build": "tsup"
27
+ },
28
+ "dependencies": {
29
+ "jose": "6.2.12"
30
+ },
31
+ "peerDependencies": {
32
+ "zod": "^4.3.0",
33
+ "react": ">=19"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "react": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "@rushy/host-sdk": "workspace:*",
42
+ "@rushy/react": "workspace:*",
43
+ "tsup": "8.5.1",
44
+ "zod": "4.6.5"
45
+ }
46
+ }