dewee-webchat 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/astro/DeweeChat.astro +21 -0
- package/dist/chunk-MKTHKYLY.js +10 -0
- package/dist/chunk-RGAYYTL7.js +1265 -0
- package/dist/dewee-webchat.js +252 -0
- package/dist/index.d.ts +161 -0
- package/dist/index.js +17 -0
- package/dist/protocol-CS8_4zC0.d.ts +71 -0
- package/dist/react.d.ts +26 -0
- package/dist/react.js +44 -0
- package/dist/server.d.ts +125 -0
- package/dist/server.js +632 -0
- package/dist/vue.d.ts +254 -0
- package/dist/vue.js +66 -0
- package/package.json +99 -6
package/dist/server.js
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HEADER_GATEWAY_TOKEN,
|
|
3
|
+
HEADER_GATEWAY_URL,
|
|
4
|
+
VERSION
|
|
5
|
+
} from "./chunk-MKTHKYLY.js";
|
|
6
|
+
|
|
7
|
+
// src/server/gateway-client.ts
|
|
8
|
+
var GatewayError = class extends Error {
|
|
9
|
+
constructor(message, code) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
code;
|
|
14
|
+
};
|
|
15
|
+
function wsUrl(base) {
|
|
16
|
+
const u = new URL(base);
|
|
17
|
+
u.protocol = u.protocol === "http:" ? "ws:" : "wss:";
|
|
18
|
+
u.pathname = u.pathname.replace(/\/$/, "") + "/ws";
|
|
19
|
+
return u.toString();
|
|
20
|
+
}
|
|
21
|
+
async function openSocket(base, timeoutMs) {
|
|
22
|
+
if (typeof globalThis.WebSocketPair !== "undefined") {
|
|
23
|
+
const httpUrl = wsUrl(base).replace(/^ws/, "http");
|
|
24
|
+
const res = await fetch(httpUrl, { headers: { Upgrade: "websocket" } });
|
|
25
|
+
const ws2 = res.webSocket;
|
|
26
|
+
if (!ws2) throw new GatewayError(`Gateway refused WebSocket upgrade (HTTP ${res.status})`, "upgrade_failed");
|
|
27
|
+
ws2.accept();
|
|
28
|
+
return ws2;
|
|
29
|
+
}
|
|
30
|
+
const WS = globalThis.WebSocket;
|
|
31
|
+
if (!WS) throw new GatewayError("No WebSocket implementation available (Node >= 22 required)", "no_websocket");
|
|
32
|
+
const ws = new WS(wsUrl(base));
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
const t = setTimeout(() => reject(new GatewayError("Gateway connection timed out", "timeout")), timeoutMs);
|
|
35
|
+
ws.addEventListener("open", () => {
|
|
36
|
+
clearTimeout(t);
|
|
37
|
+
resolve();
|
|
38
|
+
});
|
|
39
|
+
ws.addEventListener("error", () => {
|
|
40
|
+
clearTimeout(t);
|
|
41
|
+
reject(new GatewayError("Gateway connection failed", "connect_failed"));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
return ws;
|
|
45
|
+
}
|
|
46
|
+
var GatewayConnection = class _GatewayConnection {
|
|
47
|
+
constructor(ws) {
|
|
48
|
+
this.ws = ws;
|
|
49
|
+
ws.addEventListener("message", (ev) => this.onMessage(typeof ev.data === "string" ? ev.data : String(ev.data)));
|
|
50
|
+
ws.addEventListener("close", () => this.fail(new GatewayError("Gateway connection closed", "closed")));
|
|
51
|
+
ws.addEventListener("error", () => this.fail(new GatewayError("Gateway connection error", "socket_error")));
|
|
52
|
+
}
|
|
53
|
+
ws;
|
|
54
|
+
seq = 0;
|
|
55
|
+
pending = /* @__PURE__ */ new Map();
|
|
56
|
+
listeners = /* @__PURE__ */ new Set();
|
|
57
|
+
closed = false;
|
|
58
|
+
static async connect(creds, userId, timeoutMs = 15e3) {
|
|
59
|
+
const conn = new _GatewayConnection(await openSocket(creds.url, timeoutMs));
|
|
60
|
+
const params = { token: creds.token, user_id: userId };
|
|
61
|
+
if (creds.tenantId) params.tenant_id = creds.tenantId;
|
|
62
|
+
try {
|
|
63
|
+
await conn.call("connect", params, timeoutMs);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
conn.close();
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
return conn;
|
|
69
|
+
}
|
|
70
|
+
call(method, params, timeoutMs = 0) {
|
|
71
|
+
if (this.closed) return Promise.reject(new GatewayError("Gateway connection closed", "closed"));
|
|
72
|
+
const id = `r${++this.seq}`;
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
let timer;
|
|
75
|
+
if (timeoutMs > 0) timer = setTimeout(() => {
|
|
76
|
+
this.pending.delete(id);
|
|
77
|
+
reject(new GatewayError(`${method} timed out`, "timeout"));
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
this.pending.set(id, {
|
|
80
|
+
resolve: (v) => {
|
|
81
|
+
if (timer) clearTimeout(timer);
|
|
82
|
+
resolve(v);
|
|
83
|
+
},
|
|
84
|
+
reject: (e) => {
|
|
85
|
+
if (timer) clearTimeout(timer);
|
|
86
|
+
reject(e);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
this.ws.send(JSON.stringify({ type: "req", id, method, params }));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
onEvent(cb) {
|
|
93
|
+
this.listeners.add(cb);
|
|
94
|
+
return () => this.listeners.delete(cb);
|
|
95
|
+
}
|
|
96
|
+
close() {
|
|
97
|
+
if (this.closed) return;
|
|
98
|
+
this.closed = true;
|
|
99
|
+
try {
|
|
100
|
+
this.ws.close(1e3, "done");
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
this.fail(new GatewayError("Gateway connection closed", "closed"));
|
|
104
|
+
}
|
|
105
|
+
onMessage(raw) {
|
|
106
|
+
let frame;
|
|
107
|
+
try {
|
|
108
|
+
frame = JSON.parse(raw);
|
|
109
|
+
} catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (frame.type === "res" && frame.id && this.pending.has(frame.id)) {
|
|
113
|
+
const p = this.pending.get(frame.id);
|
|
114
|
+
this.pending.delete(frame.id);
|
|
115
|
+
if (frame.ok) p.resolve(frame.payload);
|
|
116
|
+
else p.reject(new GatewayError(frame.error?.message || "Gateway request failed", frame.error?.code));
|
|
117
|
+
} else if (frame.type === "event" && frame.event) {
|
|
118
|
+
for (const cb of this.listeners) cb(frame.event, frame.payload);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
fail(err) {
|
|
122
|
+
this.closed = true;
|
|
123
|
+
for (const p of this.pending.values()) p.reject(err);
|
|
124
|
+
this.pending.clear();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
function buildSessionKey(agent, conversationId) {
|
|
128
|
+
return `agent:${agent}:ws:direct:${conversationId}`;
|
|
129
|
+
}
|
|
130
|
+
async function* streamChat(opts) {
|
|
131
|
+
const conn = await GatewayConnection.connect(opts.creds, opts.userId);
|
|
132
|
+
const queue = [];
|
|
133
|
+
let wake = null;
|
|
134
|
+
let finished = false;
|
|
135
|
+
const push = (e) => {
|
|
136
|
+
queue.push(e);
|
|
137
|
+
wake?.();
|
|
138
|
+
};
|
|
139
|
+
const end = () => {
|
|
140
|
+
finished = true;
|
|
141
|
+
wake?.();
|
|
142
|
+
};
|
|
143
|
+
const off = conn.onEvent((event, payload) => {
|
|
144
|
+
if (event !== "agent" || !payload) return;
|
|
145
|
+
const key = payload.visibleSessionKey || payload.sessionKey;
|
|
146
|
+
if (key && key !== opts.sessionKey && payload.runSessionKey !== opts.sessionKey) return;
|
|
147
|
+
const p = payload.payload || {};
|
|
148
|
+
switch (payload.type) {
|
|
149
|
+
case "run.started":
|
|
150
|
+
push({ type: "start", runId: payload.runId, conversationId: "", agent: opts.agent });
|
|
151
|
+
break;
|
|
152
|
+
case "thinking":
|
|
153
|
+
if (p.content) push({ type: "thinking", text: p.content });
|
|
154
|
+
break;
|
|
155
|
+
case "chunk":
|
|
156
|
+
if (p.content) push({ type: "delta", text: p.content });
|
|
157
|
+
break;
|
|
158
|
+
case "tool.call":
|
|
159
|
+
push({ type: "tool_call", id: p.id || "", name: p.name || "tool", arguments: p.arguments });
|
|
160
|
+
break;
|
|
161
|
+
case "tool.result":
|
|
162
|
+
push({ type: "tool_result", id: p.id || "", name: p.name || "tool", isError: !!p.is_error, result: clip(p.result ?? p.content) });
|
|
163
|
+
break;
|
|
164
|
+
case "run.failed":
|
|
165
|
+
push({ type: "error", message: p.error || p.message || "Agent run failed", code: "run_failed" });
|
|
166
|
+
end();
|
|
167
|
+
break;
|
|
168
|
+
case "run.cancelled":
|
|
169
|
+
push({ type: "error", message: "Run cancelled", code: "cancelled" });
|
|
170
|
+
end();
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
const onAbort = () => {
|
|
175
|
+
conn.call("chat.abort", { sessionKey: opts.sessionKey }, 3e3).catch(() => {
|
|
176
|
+
}).finally(() => conn.close());
|
|
177
|
+
end();
|
|
178
|
+
};
|
|
179
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
180
|
+
conn.call("chat.send", { agentId: opts.agent, sessionKey: opts.sessionKey, message: opts.message, stream: true }, opts.timeoutMs ?? 18e4).then((res) => {
|
|
181
|
+
push({ type: "done", content: res?.content ?? "", usage: res?.usage });
|
|
182
|
+
end();
|
|
183
|
+
}).catch((err) => {
|
|
184
|
+
if (!finished) push({ type: "error", message: err.message, code: err.code });
|
|
185
|
+
end();
|
|
186
|
+
});
|
|
187
|
+
try {
|
|
188
|
+
while (true) {
|
|
189
|
+
if (queue.length) {
|
|
190
|
+
yield queue.shift();
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (finished) break;
|
|
194
|
+
await new Promise((r) => wake = r);
|
|
195
|
+
wake = null;
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
off();
|
|
199
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
200
|
+
conn.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function clip(v, max = 4e3) {
|
|
204
|
+
if (v == null) return void 0;
|
|
205
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
206
|
+
return s.length > max ? s.slice(0, max) + "\u2026" : s;
|
|
207
|
+
}
|
|
208
|
+
async function fetchHistory(creds, userId, agent, sessionKey) {
|
|
209
|
+
const conn = await GatewayConnection.connect(creds, userId);
|
|
210
|
+
try {
|
|
211
|
+
const res = await conn.call("chat.history", { agentId: agent, sessionKey }, 15e3);
|
|
212
|
+
return foldHistory(res?.messages ?? []);
|
|
213
|
+
} finally {
|
|
214
|
+
conn.close();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function foldHistory(raw) {
|
|
218
|
+
const out = [];
|
|
219
|
+
let current = null;
|
|
220
|
+
for (const m of raw) {
|
|
221
|
+
if (m.role === "user") {
|
|
222
|
+
current = null;
|
|
223
|
+
out.push({ role: "user", content: String(m.content ?? ""), createdAt: m.created_at });
|
|
224
|
+
} else if (m.role === "assistant") {
|
|
225
|
+
if (!current) {
|
|
226
|
+
current = { role: "assistant", content: "", createdAt: m.created_at };
|
|
227
|
+
out.push(current);
|
|
228
|
+
}
|
|
229
|
+
if (m.thinking) current.thinking = (current.thinking ? current.thinking + "\n\n" : "") + m.thinking;
|
|
230
|
+
if (m.content) current.content = current.content ? `${current.content}
|
|
231
|
+
|
|
232
|
+
${m.content}` : String(m.content);
|
|
233
|
+
for (const tc of m.tool_calls ?? []) {
|
|
234
|
+
const view = { id: tc.id, name: tc.name ?? tc.function?.name ?? "tool", arguments: tc.arguments ?? tc.function?.arguments, status: "done" };
|
|
235
|
+
(current.toolCalls ??= []).push(view);
|
|
236
|
+
}
|
|
237
|
+
} else if (m.role === "tool" && current?.toolCalls) {
|
|
238
|
+
const tc = current.toolCalls.find((t) => t.id === m.tool_call_id);
|
|
239
|
+
if (tc) {
|
|
240
|
+
tc.result = clip(m.content);
|
|
241
|
+
if (m.is_error) {
|
|
242
|
+
tc.isError = true;
|
|
243
|
+
tc.status = "error";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out.filter((m) => m.role === "user" || m.content || m.toolCalls?.length);
|
|
249
|
+
}
|
|
250
|
+
async function listAgents(creds) {
|
|
251
|
+
const headers = { Authorization: `Bearer ${creds.token}`, "X-GoClaw-User-Id": "system" };
|
|
252
|
+
if (creds.tenantId) headers["X-GoClaw-Tenant-Id"] = creds.tenantId;
|
|
253
|
+
const res = await fetch(creds.url.replace(/\/$/, "") + "/v1/agents", { headers });
|
|
254
|
+
if (!res.ok) throw new GatewayError(`Listing agents failed (HTTP ${res.status})`, "agents_failed");
|
|
255
|
+
const body = await res.json();
|
|
256
|
+
return (body.agents ?? []).filter((a) => !a.status || a.status === "active").map((a) => ({ id: a.agent_key || a.id, name: a.display_name || a.agent_key || a.id }));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/server/security.ts
|
|
260
|
+
var enc = new TextEncoder();
|
|
261
|
+
function b64url(bytes) {
|
|
262
|
+
let s = "";
|
|
263
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
264
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
265
|
+
}
|
|
266
|
+
function fromB64url(s) {
|
|
267
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4));
|
|
268
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
269
|
+
}
|
|
270
|
+
var keyCache = /* @__PURE__ */ new Map();
|
|
271
|
+
function hmacKey(secret) {
|
|
272
|
+
let k = keyCache.get(secret);
|
|
273
|
+
if (!k) {
|
|
274
|
+
k = crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
275
|
+
keyCache.set(secret, k);
|
|
276
|
+
}
|
|
277
|
+
return k;
|
|
278
|
+
}
|
|
279
|
+
async function signVisitorToken(secret, claims) {
|
|
280
|
+
const body = b64url(enc.encode(JSON.stringify(claims)));
|
|
281
|
+
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body)));
|
|
282
|
+
return `${body}.${b64url(sig)}`;
|
|
283
|
+
}
|
|
284
|
+
async function verifyVisitorToken(secret, token) {
|
|
285
|
+
const [body, sig] = token.split(".");
|
|
286
|
+
if (!body || !sig) return null;
|
|
287
|
+
try {
|
|
288
|
+
const ok = await crypto.subtle.verify("HMAC", await hmacKey(secret), fromB64url(sig), enc.encode(body));
|
|
289
|
+
if (!ok) return null;
|
|
290
|
+
const claims = JSON.parse(new TextDecoder().decode(fromB64url(body)));
|
|
291
|
+
if (typeof claims.v !== "string" || !/^[A-Za-z0-9_-]{8,64}$/.test(claims.v)) return null;
|
|
292
|
+
if (typeof claims.exp !== "number" || claims.exp < Date.now()) return null;
|
|
293
|
+
return claims;
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function randomId(bytes = 16) {
|
|
299
|
+
return b64url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
300
|
+
}
|
|
301
|
+
function memoryRateLimiter(limits) {
|
|
302
|
+
const windows = /* @__PURE__ */ new Map();
|
|
303
|
+
return (key, bucket) => {
|
|
304
|
+
const [max, windowMs] = limits[bucket];
|
|
305
|
+
const id = `${bucket}:${key}`;
|
|
306
|
+
const now = Date.now();
|
|
307
|
+
const w = windows.get(id);
|
|
308
|
+
if (!w || now - w.start >= windowMs) {
|
|
309
|
+
windows.set(id, { start: now, count: 1 });
|
|
310
|
+
if (windows.size > 1e4) {
|
|
311
|
+
for (const [k, v] of windows) if (now - v.start >= windowMs) windows.delete(k);
|
|
312
|
+
}
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
w.count++;
|
|
316
|
+
return w.count <= max;
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
async function verifyTurnstile(secret, response, ip) {
|
|
320
|
+
const form = new FormData();
|
|
321
|
+
form.append("secret", secret);
|
|
322
|
+
form.append("response", response);
|
|
323
|
+
if (ip) form.append("remoteip", ip);
|
|
324
|
+
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body: form });
|
|
325
|
+
const data = await res.json().catch(() => ({}));
|
|
326
|
+
return data.success === true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/server/handler.ts
|
|
330
|
+
var ID_RE = /^[A-Za-z0-9_-]{6,64}$/;
|
|
331
|
+
var AGENT_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
332
|
+
function createDeweeHandler(options) {
|
|
333
|
+
const o = {
|
|
334
|
+
maxMessageLength: 4e3,
|
|
335
|
+
userIdPrefix: "webchat-",
|
|
336
|
+
sessionTtlMs: 30 * 24 * 3600 * 1e3,
|
|
337
|
+
timeoutMs: 18e4,
|
|
338
|
+
...options
|
|
339
|
+
};
|
|
340
|
+
let secret = o.secret;
|
|
341
|
+
if (!secret) {
|
|
342
|
+
secret = randomId(32);
|
|
343
|
+
console.warn("[dewee-webchat] No secret configured: using an ephemeral one. Visitor sessions reset on restart.");
|
|
344
|
+
}
|
|
345
|
+
const limiter = o.rateLimit === false ? null : o.rateLimit ?? memoryRateLimiter({ chat: [20, 6e4], session: [10, 6e5] });
|
|
346
|
+
const staticAgents = o.agents === "*" ? [] : (o.agents ?? []).map((a) => typeof a === "string" ? { id: a } : a);
|
|
347
|
+
const wildcardAgents = o.agents === "*";
|
|
348
|
+
const byo = o.allowClientCredentials ? o.allowClientCredentials === true ? {} : o.allowClientCredentials : null;
|
|
349
|
+
function serverCreds() {
|
|
350
|
+
return o.gatewayUrl && o.token ? { url: o.gatewayUrl, token: o.token, tenantId: o.tenantId } : null;
|
|
351
|
+
}
|
|
352
|
+
function resolveCreds(req) {
|
|
353
|
+
const url = req.headers.get(HEADER_GATEWAY_URL);
|
|
354
|
+
const token = req.headers.get(HEADER_GATEWAY_TOKEN);
|
|
355
|
+
if (url || token) {
|
|
356
|
+
if (!byo) return { creds: null, byo: true, error: "Client credentials are disabled on this server" };
|
|
357
|
+
if (!url || !token) return { creds: null, byo: true, error: "Both gateway URL and token are required" };
|
|
358
|
+
let u;
|
|
359
|
+
try {
|
|
360
|
+
u = new URL(url);
|
|
361
|
+
} catch {
|
|
362
|
+
return { creds: null, byo: true, error: "Invalid gateway URL" };
|
|
363
|
+
}
|
|
364
|
+
const listed = !!byo.allowedHosts?.includes(u.hostname);
|
|
365
|
+
if (byo.allowedHosts?.length && !listed) return { creds: null, byo: true, error: "Gateway host not allowed" };
|
|
366
|
+
if (isInternalHost(u.hostname) && !listed) return { creds: null, byo: true, error: "Gateway host not allowed" };
|
|
367
|
+
if (u.protocol !== "https:" && !(listed && u.protocol === "http:")) return { creds: null, byo: true, error: "Gateway URL must use https" };
|
|
368
|
+
return { creds: { url: u.origin + u.pathname.replace(/\/$/, ""), token }, byo: true };
|
|
369
|
+
}
|
|
370
|
+
return { creds: serverCreds(), byo: false };
|
|
371
|
+
}
|
|
372
|
+
async function agentsFor(creds, isByo) {
|
|
373
|
+
if ((isByo || wildcardAgents) && creds) return listAgents(creds);
|
|
374
|
+
if (creds && staticAgents.some((a) => !a.name)) {
|
|
375
|
+
namedAgents ??= listAgents(creds).then((all) => staticAgents.map((a) => ({ ...a, name: a.name ?? all.find((x) => x.id === a.id)?.name }))).catch(() => {
|
|
376
|
+
namedAgents = void 0;
|
|
377
|
+
return staticAgents;
|
|
378
|
+
});
|
|
379
|
+
return namedAgents;
|
|
380
|
+
}
|
|
381
|
+
return staticAgents;
|
|
382
|
+
}
|
|
383
|
+
let namedAgents;
|
|
384
|
+
function corsHeaders(req) {
|
|
385
|
+
const origin = req.headers.get("origin");
|
|
386
|
+
if (!origin) return {};
|
|
387
|
+
const allowed = o.allowedOrigins === "*" || (o.allowedOrigins ?? []).includes(origin) || origin === new URL(req.url).origin;
|
|
388
|
+
if (!allowed) return {};
|
|
389
|
+
return {
|
|
390
|
+
"Access-Control-Allow-Origin": origin,
|
|
391
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
392
|
+
"Access-Control-Allow-Headers": `content-type, authorization, ${HEADER_GATEWAY_URL}, ${HEADER_GATEWAY_TOKEN}`,
|
|
393
|
+
"Access-Control-Max-Age": "600",
|
|
394
|
+
Vary: "Origin"
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function originAllowed(req) {
|
|
398
|
+
const origin = req.headers.get("origin");
|
|
399
|
+
if (!origin || origin === new URL(req.url).origin) return true;
|
|
400
|
+
return o.allowedOrigins === "*" || (o.allowedOrigins ?? []).includes(origin);
|
|
401
|
+
}
|
|
402
|
+
const json = (req, body, status = 200) => new Response(JSON.stringify(body), {
|
|
403
|
+
status,
|
|
404
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...corsHeaders(req) }
|
|
405
|
+
});
|
|
406
|
+
const fail = (req, status, message, code = "bad_request") => json(req, { error: { message, code } }, status);
|
|
407
|
+
const clientIp = (req) => req.headers.get("cf-connecting-ip") || req.headers.get("x-real-ip") || req.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown";
|
|
408
|
+
async function visitorFrom(req) {
|
|
409
|
+
const auth = req.headers.get("authorization") || "";
|
|
410
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
411
|
+
const claims = token ? await verifyVisitorToken(secret, token) : null;
|
|
412
|
+
return claims?.v ?? null;
|
|
413
|
+
}
|
|
414
|
+
async function pickAgent(requested, creds, isByo) {
|
|
415
|
+
if (isByo || wildcardAgents) {
|
|
416
|
+
const a = requested || o.defaultAgent || (await agentsFor(creds, isByo))[0]?.id;
|
|
417
|
+
return a && AGENT_RE.test(a) ? a : null;
|
|
418
|
+
}
|
|
419
|
+
const def = o.defaultAgent || staticAgents[0]?.id;
|
|
420
|
+
if (!requested) return def ?? null;
|
|
421
|
+
return staticAgents.some((a) => a.id === requested) ? requested : null;
|
|
422
|
+
}
|
|
423
|
+
async function handleConfig(req) {
|
|
424
|
+
const { creds, byo: isByo, error } = resolveCreds(req);
|
|
425
|
+
if (error) return fail(req, 400, error, "bad_credentials");
|
|
426
|
+
let agents = [];
|
|
427
|
+
try {
|
|
428
|
+
agents = await agentsFor(creds, isByo);
|
|
429
|
+
} catch (e) {
|
|
430
|
+
return fail(req, 502, e.message, "gateway_error");
|
|
431
|
+
}
|
|
432
|
+
const body = {
|
|
433
|
+
agents,
|
|
434
|
+
defaultAgent: !isByo && o.defaultAgent || agents[0]?.id || "",
|
|
435
|
+
allowAgentSwitch: o.allowAgentSwitch ?? (isByo || agents.length > 1),
|
|
436
|
+
allowClientCredentials: !!byo,
|
|
437
|
+
turnstileSiteKey: o.turnstile?.siteKey,
|
|
438
|
+
maxMessageLength: o.maxMessageLength,
|
|
439
|
+
version: VERSION
|
|
440
|
+
};
|
|
441
|
+
return json(req, body);
|
|
442
|
+
}
|
|
443
|
+
async function handleSession(req) {
|
|
444
|
+
const ip = clientIp(req);
|
|
445
|
+
if (limiter && !await limiter(ip, "session")) return fail(req, 429, "Too many sessions, slow down", "rate_limited");
|
|
446
|
+
const body = await req.json().catch(() => ({}));
|
|
447
|
+
if (o.turnstile) {
|
|
448
|
+
if (!body?.turnstileToken || !await verifyTurnstile(o.turnstile.secretKey, String(body.turnstileToken), ip)) {
|
|
449
|
+
return fail(req, 403, "Verification failed", "turnstile_failed");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const previous = typeof body?.token === "string" ? await verifyVisitorToken(secret, body.token) : null;
|
|
453
|
+
const visitorId = previous?.v ?? randomId(16);
|
|
454
|
+
const expiresAt = Date.now() + o.sessionTtlMs;
|
|
455
|
+
return json(req, { visitorId, token: await signVisitorToken(secret, { v: visitorId, exp: expiresAt }), expiresAt });
|
|
456
|
+
}
|
|
457
|
+
async function handleChat(req) {
|
|
458
|
+
const visitorId = await visitorFrom(req);
|
|
459
|
+
if (!visitorId) return fail(req, 401, "Missing or expired visitor session", "unauthorized");
|
|
460
|
+
if (limiter && !await limiter(`${visitorId}|${clientIp(req)}`, "chat")) return fail(req, 429, "Too many messages, please wait a moment", "rate_limited");
|
|
461
|
+
const { creds, byo: isByo, error } = resolveCreds(req);
|
|
462
|
+
if (error) return fail(req, 400, error, "bad_credentials");
|
|
463
|
+
if (!creds) return fail(req, 500, "Gateway is not configured", "not_configured");
|
|
464
|
+
const body = await req.json().catch(() => null);
|
|
465
|
+
const message = typeof body?.message === "string" ? body.message.trim() : "";
|
|
466
|
+
if (!message) return fail(req, 400, "Message is required");
|
|
467
|
+
if (message.length > o.maxMessageLength) return fail(req, 413, `Message exceeds ${o.maxMessageLength} characters`, "too_long");
|
|
468
|
+
const conversationId = typeof body?.conversationId === "string" && ID_RE.test(body.conversationId) ? body.conversationId : randomId(9);
|
|
469
|
+
const agent = await pickAgent(body?.agent, creds, isByo);
|
|
470
|
+
if (!agent) return fail(req, 403, "Agent not allowed", "agent_not_allowed");
|
|
471
|
+
const sessionKey = buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`);
|
|
472
|
+
const abort = new AbortController();
|
|
473
|
+
req.signal?.addEventListener("abort", () => abort.abort(), { once: true });
|
|
474
|
+
const { readable, writable } = new TransformStream();
|
|
475
|
+
const writer = writable.getWriter();
|
|
476
|
+
const enc2 = new TextEncoder();
|
|
477
|
+
const send = (e) => writer.write(enc2.encode(typeof e === "string" ? e : `data: ${JSON.stringify(e)}
|
|
478
|
+
|
|
479
|
+
`)).catch(() => abort.abort());
|
|
480
|
+
(async () => {
|
|
481
|
+
const keepAlive = setInterval(() => send(": ping\n\n"), 15e3);
|
|
482
|
+
let reply = "";
|
|
483
|
+
let errMsg;
|
|
484
|
+
try {
|
|
485
|
+
await send({ type: "start", conversationId, agent });
|
|
486
|
+
for await (const ev of streamChat({ creds, userId: o.userIdPrefix + visitorId, agent, sessionKey, message, signal: abort.signal, timeoutMs: o.timeoutMs })) {
|
|
487
|
+
if (ev.type === "start") continue;
|
|
488
|
+
if (ev.type === "done") reply = ev.content;
|
|
489
|
+
if (ev.type === "error") errMsg = ev.message;
|
|
490
|
+
await send(ev);
|
|
491
|
+
if (abort.signal.aborted) break;
|
|
492
|
+
}
|
|
493
|
+
} catch (e) {
|
|
494
|
+
errMsg = e.message;
|
|
495
|
+
await send({ type: "error", message: errMsg, code: "gateway_error" });
|
|
496
|
+
} finally {
|
|
497
|
+
clearInterval(keepAlive);
|
|
498
|
+
try {
|
|
499
|
+
o.onChat?.({ visitorId, agent, message, reply, error: errMsg });
|
|
500
|
+
} catch {
|
|
501
|
+
}
|
|
502
|
+
writer.close().catch(() => {
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
})();
|
|
506
|
+
return new Response(readable, {
|
|
507
|
+
headers: {
|
|
508
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
509
|
+
"cache-control": "no-cache, no-transform",
|
|
510
|
+
"x-accel-buffering": "no",
|
|
511
|
+
...corsHeaders(req)
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
async function handleHistory(req) {
|
|
516
|
+
const visitorId = await visitorFrom(req);
|
|
517
|
+
if (!visitorId) return fail(req, 401, "Missing or expired visitor session", "unauthorized");
|
|
518
|
+
const { creds, byo: isByo, error } = resolveCreds(req);
|
|
519
|
+
if (error) return fail(req, 400, error, "bad_credentials");
|
|
520
|
+
if (!creds) return fail(req, 500, "Gateway is not configured", "not_configured");
|
|
521
|
+
const url = new URL(req.url);
|
|
522
|
+
const conversationId = url.searchParams.get("conversationId") || "";
|
|
523
|
+
if (!ID_RE.test(conversationId)) return fail(req, 400, "Invalid conversationId");
|
|
524
|
+
const agent = await pickAgent(url.searchParams.get("agent"), creds, isByo);
|
|
525
|
+
if (!agent) return fail(req, 403, "Agent not allowed", "agent_not_allowed");
|
|
526
|
+
try {
|
|
527
|
+
const messages = await fetchHistory(creds, o.userIdPrefix + visitorId, agent, buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`));
|
|
528
|
+
return json(req, { messages });
|
|
529
|
+
} catch (e) {
|
|
530
|
+
if (e.code === "NOT_FOUND") return json(req, { messages: [] });
|
|
531
|
+
return fail(req, 502, e.message, "gateway_error");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return async function deweeHandler(req) {
|
|
535
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders(req) });
|
|
536
|
+
if (!originAllowed(req)) return fail(req, 403, "Origin not allowed", "forbidden_origin");
|
|
537
|
+
const action = new URL(req.url).pathname.replace(/\/+$/, "").split("/").pop();
|
|
538
|
+
try {
|
|
539
|
+
if (req.method === "GET" && action === "config") return await handleConfig(req);
|
|
540
|
+
if (req.method === "POST" && action === "session") return await handleSession(req);
|
|
541
|
+
if (req.method === "POST" && action === "chat") return await handleChat(req);
|
|
542
|
+
if (req.method === "GET" && action === "history") return await handleHistory(req);
|
|
543
|
+
return fail(req, 404, "Not found", "not_found");
|
|
544
|
+
} catch (e) {
|
|
545
|
+
return fail(req, 500, e.message || "Internal error", "internal");
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
function optionsFromEnv(env, overrides = {}) {
|
|
550
|
+
const list = (v) => v?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
551
|
+
const agents = env.DEWEE_AGENTS?.trim() === "*" ? "*" : list(env.DEWEE_AGENTS);
|
|
552
|
+
const origins = env.DEWEE_ALLOWED_ORIGINS?.trim() === "*" ? "*" : list(env.DEWEE_ALLOWED_ORIGINS);
|
|
553
|
+
const byoHosts = list(env.DEWEE_CLIENT_CREDENTIAL_HOSTS);
|
|
554
|
+
return {
|
|
555
|
+
gatewayUrl: env.DEWEE_GATEWAY_URL || env.GOCLAW_GATEWAY_URL,
|
|
556
|
+
token: env.DEWEE_GATEWAY_TOKEN || env.GOCLAW_GATEWAY_TOKEN,
|
|
557
|
+
tenantId: env.DEWEE_TENANT_ID,
|
|
558
|
+
secret: env.DEWEE_WEBCHAT_SECRET,
|
|
559
|
+
agents,
|
|
560
|
+
defaultAgent: env.DEWEE_DEFAULT_AGENT,
|
|
561
|
+
allowedOrigins: origins,
|
|
562
|
+
allowClientCredentials: env.DEWEE_ALLOW_CLIENT_CREDENTIALS === "true" ? byoHosts?.length ? { allowedHosts: byoHosts } : true : false,
|
|
563
|
+
turnstile: env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET_KEY ? { siteKey: env.TURNSTILE_SITE_KEY, secretKey: env.TURNSTILE_SECRET_KEY } : void 0,
|
|
564
|
+
// undefined overrides must not erase env-derived values
|
|
565
|
+
...Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== void 0))
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function isInternalHost(host) {
|
|
569
|
+
const h = host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
570
|
+
if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".internal") || h.endsWith(".local") || !h.includes(".") && !h.includes(":")) return true;
|
|
571
|
+
const v4 = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
572
|
+
if (v4) {
|
|
573
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
|
574
|
+
return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
|
|
575
|
+
}
|
|
576
|
+
return h.includes(":") && (h === "::1" || h === "::" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80") || h.startsWith("::ffff:"));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/server/node.ts
|
|
580
|
+
function toNodeHandler(handler) {
|
|
581
|
+
return async (req, res) => {
|
|
582
|
+
const proto = req.headers["x-forwarded-proto"]?.split(",")[0] || (req.socket.encrypted ? "https" : "http");
|
|
583
|
+
const url = new URL(req.originalUrl || req.url || "/", `${proto}://${req.headers.host || "localhost"}`);
|
|
584
|
+
const headers = new Headers();
|
|
585
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
586
|
+
if (Array.isArray(v)) v.forEach((x) => headers.append(k, x));
|
|
587
|
+
else if (v != null) headers.set(k, v);
|
|
588
|
+
}
|
|
589
|
+
const abort = new AbortController();
|
|
590
|
+
res.on("close", () => {
|
|
591
|
+
if (!res.writableFinished) abort.abort();
|
|
592
|
+
});
|
|
593
|
+
let body;
|
|
594
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
595
|
+
if (req.body !== void 0) body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
|
|
596
|
+
else {
|
|
597
|
+
const chunks = [];
|
|
598
|
+
for await (const c of req) chunks.push(c);
|
|
599
|
+
body = Buffer.concat(chunks);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const response = await handler(new Request(url, { method: req.method, headers, body, signal: abort.signal }));
|
|
603
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
604
|
+
if (!response.body) return void res.end();
|
|
605
|
+
const reader = response.body.getReader();
|
|
606
|
+
try {
|
|
607
|
+
while (true) {
|
|
608
|
+
const { done, value } = await reader.read();
|
|
609
|
+
if (done) break;
|
|
610
|
+
res.write(value);
|
|
611
|
+
}
|
|
612
|
+
} catch {
|
|
613
|
+
} finally {
|
|
614
|
+
res.end();
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
export {
|
|
619
|
+
GatewayConnection,
|
|
620
|
+
GatewayError,
|
|
621
|
+
buildSessionKey,
|
|
622
|
+
createDeweeHandler,
|
|
623
|
+
fetchHistory,
|
|
624
|
+
foldHistory,
|
|
625
|
+
listAgents,
|
|
626
|
+
memoryRateLimiter,
|
|
627
|
+
optionsFromEnv,
|
|
628
|
+
signVisitorToken,
|
|
629
|
+
streamChat,
|
|
630
|
+
toNodeHandler,
|
|
631
|
+
verifyVisitorToken
|
|
632
|
+
};
|