@ooky/sdk 0.1.0 → 0.6.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/src/core.js CHANGED
@@ -4,15 +4,69 @@
4
4
  * The handler exposes:
5
5
  * - matchPath(path) → null | "llms" | "llms-full" | "manifest" | "agents" | "mcp"
6
6
  * - detectBot(ua) → null | { name, pattern, category }
7
- * - serveManifest(kind, ctx?) → { status, body, headers }
7
+ * - serveManifest(kind) → { status, body, headers }
8
8
  * - recordEvent(payload) → fire-and-forget POST to /api/ingest/events
9
9
  * - refreshBotRegistry() → optional manual refresh from /api/public/bots
10
10
  *
11
11
  * Adapters (express/next/edge) wrap this with the framework's request/response
12
12
  * conventions but never duplicate logic.
13
+ *
14
+ * IMPORTANT: never import Node-only APIs here (no `node:*`, no `Buffer`, no
15
+ * `setImmediate`) — this file runs verbatim on the Vercel Edge runtime.
13
16
  */
14
17
 
15
- import { detectBot as detectFromList, DEFAULT_BOTS } from "./bots.js";
18
+ import { detectBot as detectFromList, DEFAULT_BOTS, sanitizeBotRegistry } from "./bots.js";
19
+ import { detectAIReferral } from "./referrals.js";
20
+ import { handleMcpJsonRpc, McpToolError } from "./mcp.js";
21
+
22
+ export const SDK_VERSION = "0.6.0";
23
+
24
+ // Defensive length caps on untrusted strings copied into the event payload.
25
+ // The load-bearing clamp is server-side (owned by the backend), but capping
26
+ // here keeps a pathological UA / very long path from being POSTed at all.
27
+ export const MAX_UA_LENGTH = 1024;
28
+ export const MAX_PATH_LENGTH = 2048;
29
+
30
+ /** Truncate an untrusted string to `max` chars; pass through non-strings. */
31
+ export function clampString(value, max) {
32
+ if (typeof value !== "string") return value;
33
+ return value.length > max ? value.slice(0, max) : value;
34
+ }
35
+
36
+ /**
37
+ * Log (loudly) that the middleware could not be constructed — almost always a
38
+ * missing/invalid OOKY_API_KEY or OOKY_DOMAIN. The adapters call this and then
39
+ * return a pass-through middleware: a misconfigured integration must NEVER take
40
+ * down the customer's site (the middleware runs on every request). Never throws.
41
+ */
42
+ export function logMiddlewareDisabled(err) {
43
+ const msg = err && err.message ? err.message : String(err);
44
+ try {
45
+ console.error(
46
+ `[@ooky/sdk] Middleware disabled — ${msg}. Requests pass through ` +
47
+ `untouched (your app is unaffected); Ooky features are inactive. ` +
48
+ `Check that OOKY_API_KEY and OOKY_DOMAIN are set.`
49
+ );
50
+ } catch {
51
+ // console may be unavailable in exotic runtimes — never throw from here.
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Hard cap on the MCP request body the adapters will read before answering.
57
+ * The Express adapter enforces this while streaming; the Next/Edge adapter
58
+ * uses it for a Content-Length pre-check. Exported so both adapters (and the
59
+ * Worker tier, for parity) share one number.
60
+ */
61
+ export const MAX_MCP_BODY_BYTES = 64 * 1024;
62
+
63
+ /**
64
+ * Runtime tag stamped into the `X-Ooky-Sdk` response header so we don't claim
65
+ * "node" on the Edge/Web runtime. Best-effort detection — falls back to
66
+ * "node" only when we positively look like Node (has `process.versions.node`
67
+ * and no Web-runtime markers).
68
+ */
69
+ export const SDK_RUNTIME = detectRuntime();
16
70
 
17
71
  const DEFAULT_API_BASE = "https://api.ooky.ai/api";
18
72
  // Manifest content is served from the public Ooky API by default. Customers
@@ -20,7 +74,9 @@ const DEFAULT_API_BASE = "https://api.ooky.ai/api";
20
74
  // CloudFront) sitting in front of `${apiBase}/public/manifest`.
21
75
  const DEFAULT_CDN_PATH = "/public/manifest";
22
76
 
23
- // Five well-known paths the SDK answers on behalf of the customer's app.
77
+ // Well-known paths the SDK answers on behalf of the customer's app. Must stay
78
+ // feature-equivalent with worker/src/index.js (the Worker also serves bare
79
+ // /mcp because some platforms intercept /.well-known/*).
24
80
  const PATH_MAP = {
25
81
  "/llms.txt": "llms",
26
82
  "/llms-full.txt": "llms-full",
@@ -28,6 +84,7 @@ const PATH_MAP = {
28
84
  "/ai-manifest.json": "manifest",
29
85
  "/agents.md": "agents",
30
86
  "/.well-known/mcp": "mcp",
87
+ "/mcp": "mcp",
31
88
  };
32
89
 
33
90
  const CONTENT_TYPE = {
@@ -38,6 +95,13 @@ const CONTENT_TYPE = {
38
95
  mcp: "application/json; charset=utf-8",
39
96
  };
40
97
 
98
+ const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
99
+ const DEFAULT_MAX_EVENTS_PER_MINUTE = 300;
100
+ const THROTTLE_REPORT_INTERVAL_MS = 10_000;
101
+ const DEFAULT_MANIFEST_CACHE_TTL_MS = 5 * 60 * 1000; // mirrors Cache-Control max-age=300
102
+ const MANIFEST_STALE_MAX_MS = 24 * 60 * 60 * 1000; // serve stale up to 24h on upstream failure
103
+ const BOT_REFRESH_MS = 60 * 60 * 1000; // 1 hour
104
+
41
105
  /**
42
106
  * Create an Ooky handler instance.
43
107
  *
@@ -48,6 +112,18 @@ const CONTENT_TYPE = {
48
112
  * @param {string} [options.cdnBase] Override manifest CDN base URL.
49
113
  * @param {Array} [options.bots] Override the bot registry; default uses DEFAULT_BOTS.
50
114
  * @param {boolean}[options.autoRefreshBots=true] Periodically refresh from /api/public/bots.
115
+ * @param {number} [options.fetchTimeoutMs=10000] Upstream fetch timeout (manifest + registry).
116
+ * @param {number} [options.manifestCacheTtlMs=300000] In-memory manifest cache TTL. 0 disables.
117
+ * @param {Function}[options.onError] Called with (error, context) for every failure the
118
+ * SDK swallows (event POST rejections/non-2xx, manifest
119
+ * fetch failures, registry refresh failures). Use it to
120
+ * surface rotated keys / outages in your own logs.
121
+ * context = { op, status?, kind?, throttled? }. Never
122
+ * awaited; its own throws are swallowed.
123
+ * @param {number} [options.maxEventsPerMinute=300] Token-bucket cap on event POSTs so a
124
+ * bot storm can't turn your server into an unbounded
125
+ * POST cannon. Drops are reported via onError (at most
126
+ * once per 10s). Pass Infinity to disable.
51
127
  */
52
128
  export function createOokyHandler(options) {
53
129
  if (!options || typeof options !== "object") {
@@ -67,29 +143,123 @@ export function createOokyHandler(options) {
67
143
  "cdnBase"
68
144
  ).replace(/\/+$/, "");
69
145
  const autoRefreshBots = options.autoRefreshBots !== false;
146
+ const onError = typeof options.onError === "function" ? options.onError : null;
147
+ const maxEventsPerMinute =
148
+ options.maxEventsPerMinute === Infinity
149
+ ? Infinity
150
+ : positiveNumber(options.maxEventsPerMinute, DEFAULT_MAX_EVENTS_PER_MINUTE);
151
+ const fetchTimeoutMs = positiveNumber(options.fetchTimeoutMs, DEFAULT_FETCH_TIMEOUT_MS);
152
+ const manifestCacheTtlMs =
153
+ options.manifestCacheTtlMs === 0
154
+ ? 0
155
+ : positiveNumber(options.manifestCacheTtlMs, DEFAULT_MANIFEST_CACHE_TTL_MS);
70
156
 
71
- let botRegistry = options.bots || DEFAULT_BOTS;
157
+ // A customer-supplied `options.bots` typo (non-array, entries with no
158
+ // pattern, an empty-string pattern, a multi-thousand-entry blob) must not be
159
+ // able to crash the request path or match every UA. Sanitise on the way in;
160
+ // fall back to the built-in list when the override is unusable.
161
+ let botRegistry = DEFAULT_BOTS;
162
+ if (options.bots !== undefined) {
163
+ const cleaned = sanitizeBotRegistry(options.bots);
164
+ botRegistry = cleaned && cleaned.length > 0 ? cleaned : DEFAULT_BOTS;
165
+ }
72
166
  let lastBotRefresh = 0;
73
- const BOT_REFRESH_MS = 60 * 60 * 1000; // 1 hour
167
+ let botEtag = null;
168
+ let botRefreshInFlight = null;
169
+
170
+ // kind → { status, headers, body, freshUntil, staleUntil }
171
+ const manifestCache = new Map();
172
+ // kind → Promise — dedupes concurrent cold-cache fetches for the same kind.
173
+ const manifestInFlight = new Map();
174
+
175
+ /** Report a swallowed failure to the customer's onError hook, if set. */
176
+ function reportError(error, context) {
177
+ if (!onError) return;
178
+ try {
179
+ onError(error, context);
180
+ } catch {
181
+ // The hook itself must never break the request path.
182
+ }
183
+ }
184
+
185
+ // Token bucket for event POSTs. Refills continuously at the per-minute
186
+ // rate; bursts up to one minute's allowance.
187
+ let eventTokens = maxEventsPerMinute;
188
+ let lastRefillAt = Date.now();
189
+ let droppedSinceReport = 0;
190
+ let lastDropReportAt = 0;
191
+
192
+ function takeEventToken() {
193
+ if (!Number.isFinite(maxEventsPerMinute)) return true;
194
+ const now = Date.now();
195
+ eventTokens = Math.min(
196
+ maxEventsPerMinute,
197
+ eventTokens + ((now - lastRefillAt) / 60_000) * maxEventsPerMinute
198
+ );
199
+ lastRefillAt = now;
200
+ if (eventTokens >= 1) {
201
+ eventTokens -= 1;
202
+ return true;
203
+ }
204
+ droppedSinceReport += 1;
205
+ if (now - lastDropReportAt >= THROTTLE_REPORT_INTERVAL_MS) {
206
+ lastDropReportAt = now;
207
+ const dropped = droppedSinceReport;
208
+ droppedSinceReport = 0;
209
+ reportError(
210
+ new Error(
211
+ `event throttle: dropped ${dropped} event(s) (maxEventsPerMinute=${maxEventsPerMinute})`
212
+ ),
213
+ { op: "recordEvent", throttled: true }
214
+ );
215
+ }
216
+ return false;
217
+ }
74
218
 
75
219
  async function refreshBotRegistry(force = false) {
76
220
  if (!autoRefreshBots && !force) return botRegistry;
77
221
  if (!force && Date.now() - lastBotRefresh < BOT_REFRESH_MS) return botRegistry;
78
- try {
79
- const res = await fetch(`${apiBase}/public/bots`, {
80
- headers: { Accept: "application/json" },
81
- });
82
- if (res.ok) {
83
- const data = await res.json();
84
- if (Array.isArray(data?.bots) && data.bots.length > 0) {
85
- botRegistry = data.bots;
222
+ if (botRefreshInFlight) return botRefreshInFlight;
223
+
224
+ botRefreshInFlight = (async () => {
225
+ try {
226
+ const headers = { Accept: "application/json" };
227
+ if (botEtag) headers["If-None-Match"] = botEtag;
228
+ const res = await fetch(`${apiBase}/public/bots`, {
229
+ headers,
230
+ signal: timeoutSignal(fetchTimeoutMs),
231
+ });
232
+ if (res.status === 304) {
233
+ // Registry unchanged — just push the refresh window forward.
234
+ } else if (res.ok) {
235
+ const data = await res.json();
236
+ // Validate + cap before adopting: a malformed payload (empty-string
237
+ // patterns, non-string entries, a backend row-mapping regression,
238
+ // or an intermediary corrupting the JSON) must never reach
239
+ // detectBot on the hot path. A bad registry is re-fetched on every
240
+ // restart, so swallowing it here is what stops a crash loop.
241
+ const cleaned = sanitizeBotRegistry(data?.bots);
242
+ if (cleaned && cleaned.length > 0) {
243
+ botRegistry = cleaned;
244
+ botEtag = res.headers?.get?.("etag") || null;
245
+ } else {
246
+ reportError(new Error("ignored malformed /public/bots registry"), {
247
+ op: "refreshBotRegistry",
248
+ });
249
+ }
86
250
  }
251
+ lastBotRefresh = Date.now();
252
+ } catch (err) {
253
+ // Network failure — keep stale list, but still bump the timestamp so
254
+ // a dead endpoint can't trigger a refresh attempt on every request.
255
+ lastBotRefresh = Date.now();
256
+ reportError(err, { op: "refreshBotRegistry" });
257
+ } finally {
258
+ botRefreshInFlight = null;
87
259
  }
88
- lastBotRefresh = Date.now();
89
- } catch {
90
- // Network failure — keep stale list.
91
- }
92
- return botRegistry;
260
+ return botRegistry;
261
+ })();
262
+ return botRefreshInFlight;
93
263
  }
94
264
 
95
265
  function matchPath(path) {
@@ -100,6 +270,11 @@ export function createOokyHandler(options) {
100
270
  }
101
271
 
102
272
  function detectBot(userAgent) {
273
+ // Opportunistic, non-blocking registry refresh — this is the only hook
274
+ // that runs on every request, so it's where autoRefreshBots is honoured.
275
+ if (autoRefreshBots && Date.now() - lastBotRefresh >= BOT_REFRESH_MS) {
276
+ refreshBotRegistry().catch(() => {});
277
+ }
103
278
  return detectFromList(userAgent, botRegistry);
104
279
  }
105
280
 
@@ -107,56 +282,117 @@ export function createOokyHandler(options) {
107
282
  // Edge CDN URL convention. The actual route map is owned by the backend
108
283
  // and exposed via the per-domain /api/public/manifest/:domain endpoint.
109
284
  const url = `${cdnBase}/${encodeURIComponent(domain)}/${kind}`;
110
- const res = await fetch(url, { headers: { Accept: CONTENT_TYPE[kind] || "*/*" } });
285
+ const res = await fetch(url, {
286
+ headers: { Accept: CONTENT_TYPE[kind] || "*/*" },
287
+ signal: timeoutSignal(fetchTimeoutMs),
288
+ });
111
289
  return res;
112
290
  }
113
291
 
292
+ function cacheGet(kind) {
293
+ const entry = manifestCache.get(kind);
294
+ if (!entry) return { fresh: null, stale: null };
295
+ const now = Date.now();
296
+ if (now < entry.freshUntil) return { fresh: entry, stale: entry };
297
+ if (now < entry.staleUntil) return { fresh: null, stale: entry };
298
+ manifestCache.delete(kind);
299
+ return { fresh: null, stale: null };
300
+ }
301
+
302
+ function cachePut(kind, result) {
303
+ if (manifestCacheTtlMs <= 0) return;
304
+ const now = Date.now();
305
+ manifestCache.set(kind, {
306
+ ...result,
307
+ freshUntil: now + manifestCacheTtlMs,
308
+ staleUntil: now + MANIFEST_STALE_MAX_MS,
309
+ });
310
+ }
311
+
312
+ function toResponseShape(entry) {
313
+ return { status: entry.status, headers: entry.headers, body: entry.body };
314
+ }
315
+
114
316
  /**
115
317
  * Build the response for one of the well-known paths.
116
318
  * Returns { status, headers, body } where body is a string (text formats)
117
319
  * or a JS object (JSON formats). Adapters serialize as needed.
320
+ *
321
+ * Successful responses are cached in-memory for `manifestCacheTtlMs`; when
322
+ * the upstream fails, a stale copy (up to 24h old) is served instead so a
323
+ * transient Ooky outage never breaks the customer's well-known endpoints.
118
324
  */
119
325
  async function serveManifest(kind) {
120
326
  if (!CONTENT_TYPE[kind]) {
121
327
  return { status: 404, headers: {}, body: "Unknown manifest kind" };
122
328
  }
123
- try {
124
- const res = await fetchFromCdn(kind);
125
- if (!res.ok) {
329
+
330
+ const { fresh } = cacheGet(kind);
331
+ if (fresh) return toResponseShape(fresh);
332
+
333
+ const inFlight = manifestInFlight.get(kind);
334
+ if (inFlight) return inFlight;
335
+
336
+ const promise = (async () => {
337
+ try {
338
+ const res = await fetchFromCdn(kind);
339
+ if (!res.ok) {
340
+ reportError(new Error(`manifest upstream responded ${res.status}`), {
341
+ op: "serveManifest",
342
+ kind,
343
+ status: res.status,
344
+ });
345
+ const { stale } = cacheGet(kind);
346
+ if (stale && res.status >= 500) return toResponseShape(stale);
347
+ return {
348
+ status: res.status,
349
+ headers: { "Content-Type": CONTENT_TYPE[kind] },
350
+ body: kind === "manifest" || kind === "mcp"
351
+ ? { error: `Manifest unavailable (${res.status})` }
352
+ : `Manifest unavailable (${res.status})`,
353
+ };
354
+ }
355
+ const headers = {
356
+ "Content-Type": CONTENT_TYPE[kind],
357
+ "Cache-Control": "public, max-age=300, s-maxage=600",
358
+ "X-Ooky-Sdk": `${SDK_RUNTIME}/${SDK_VERSION}`,
359
+ };
360
+ const body =
361
+ kind === "manifest" || kind === "mcp" ? await res.json() : await res.text();
362
+ const result = { status: 200, headers, body };
363
+ cachePut(kind, result);
364
+ return result;
365
+ } catch (err) {
366
+ reportError(err, { op: "serveManifest", kind });
367
+ const { stale } = cacheGet(kind);
368
+ if (stale) return toResponseShape(stale);
369
+ // Match the declared content-type for this kind: JSON kinds get a
370
+ // JSON {error} body, text kinds get a text body. Otherwise a
371
+ // manifest/mcp consumer parsing application/json chokes on text.
372
+ const isJson = kind === "manifest" || kind === "mcp";
126
373
  return {
127
- status: res.status,
374
+ status: 502,
128
375
  headers: { "Content-Type": CONTENT_TYPE[kind] },
129
- body: kind === "manifest" || kind === "mcp"
130
- ? { error: `Manifest unavailable (${res.status})` }
131
- : `Manifest unavailable (${res.status})`,
376
+ body: isJson
377
+ ? { error: `Ooky manifest fetch failed: ${err.message}` }
378
+ : `Ooky manifest fetch failed: ${err.message}`,
132
379
  };
380
+ } finally {
381
+ manifestInFlight.delete(kind);
133
382
  }
134
- const headers = {
135
- "Content-Type": CONTENT_TYPE[kind],
136
- "Cache-Control": "public, max-age=300, s-maxage=600",
137
- "X-Ooky-Sdk": "node",
138
- };
139
- if (kind === "manifest" || kind === "mcp") {
140
- const body = await res.json();
141
- return { status: 200, headers, body };
142
- }
143
- const body = await res.text();
144
- return { status: 200, headers, body };
145
- } catch (err) {
146
- return {
147
- status: 502,
148
- headers: { "Content-Type": "text/plain; charset=utf-8" },
149
- body: `Ooky manifest fetch failed: ${err.message}`,
150
- };
151
- }
383
+ })();
384
+ manifestInFlight.set(kind, promise);
385
+ return promise;
152
386
  }
153
387
 
154
388
  /**
155
- * Fire-and-forget event POST. Returns immediately; the caller should not
389
+ * Fire-and-forget event POST. Returns the underlying promise so edge
390
+ * runtimes can hand it to `event.waitUntil()`, but the caller must never
156
391
  * `await` it on the request hot path. Errors are swallowed.
157
392
  */
158
393
  function recordEvent(payload) {
159
- const body = JSON.stringify({
394
+ if (!takeEventToken()) return Promise.resolve(undefined);
395
+ const event = {
160
396
  event_id: payload.event_id || cryptoRandomId(),
161
397
  timestamp: payload.timestamp || new Date().toISOString(),
162
398
  domain, // server overrides anyway, but include for backward compat
@@ -165,7 +401,12 @@ export function createOokyHandler(options) {
165
401
  session: payload.session || null,
166
402
  geo: payload.geo || null,
167
403
  serve: payload.serve || null,
168
- });
404
+ };
405
+ // ai_referral events carry a referral block instead of a bot block —
406
+ // see backend ingest.js for how the two row types diverge.
407
+ if (payload.event_type) event.event_type = payload.event_type;
408
+ if (payload.referral) event.referral = payload.referral;
409
+ const body = JSON.stringify(event);
169
410
 
170
411
  // Use fetch with keepalive=true so it survives the response cycle.
171
412
  return fetch(`${apiBase}/ingest/events`, {
@@ -176,19 +417,140 @@ export function createOokyHandler(options) {
176
417
  },
177
418
  body,
178
419
  keepalive: true,
179
- }).catch(() => {
180
- // Fire-and-forget — never throw on the request path.
181
- });
420
+ signal: timeoutSignal(fetchTimeoutMs),
421
+ })
422
+ .then((res) => {
423
+ // A non-2xx here is the one signal a customer has that their key was
424
+ // rotated/revoked (401) or the payload drifted (400) — surface it.
425
+ if (res && !res.ok) {
426
+ reportError(new Error(`ingest responded ${res.status}`), {
427
+ op: "recordEvent",
428
+ status: res.status,
429
+ });
430
+ }
431
+ return res;
432
+ })
433
+ .catch((err) => {
434
+ // Fire-and-forget — never throw on the request path.
435
+ reportError(err, { op: "recordEvent" });
436
+ });
437
+ }
438
+
439
+ /**
440
+ * Detect an AI-platform referral (human arriving from ChatGPT, Perplexity,
441
+ * Claude, …) from a Referer header and utm_source value. Pure check — the
442
+ * adapters call this for non-bot traffic and fire an ai_referral event.
443
+ */
444
+ function detectReferral(referer, utmSource) {
445
+ return detectAIReferral(referer, utmSource);
446
+ }
447
+
448
+ // Tool surface for the MCP server. The SDK tier exposes get_brand_info
449
+ // only (product tools need feed data the SDK doesn't have) — keep in sync
450
+ // with the descriptor served by backend public_manifest.js.
451
+ const MCP_TOOLS = [
452
+ {
453
+ name: "get_brand_info",
454
+ description:
455
+ "Get brand information including company overview, products, contact, and policies.",
456
+ inputSchema: {
457
+ type: "object",
458
+ properties: {
459
+ section: {
460
+ type: "string",
461
+ description:
462
+ 'Optional section filter: "about", "products", "contact", "policies", or omit for all.',
463
+ },
464
+ },
465
+ },
466
+ },
467
+ ];
468
+
469
+ async function getBrandInfo(args) {
470
+ const manifest = await serveManifest("manifest");
471
+ if (manifest.status !== 200 || typeof manifest.body !== "object") {
472
+ throw new McpToolError("Brand information not available");
473
+ }
474
+ return filterBrandSection(manifest.body, args?.section);
475
+ }
476
+
477
+ /**
478
+ * Handle an MCP request (POST /.well-known/mcp or /mcp).
479
+ *
480
+ * Speaks two protocols:
481
+ * - Standard MCP — JSON-RPC 2.0 over streamable HTTP (initialize,
482
+ * tools/list, tools/call). This is what real MCP clients (Claude, MCP
483
+ * Inspector) use. Detected by `jsonrpc: "2.0"` on the body.
484
+ * - Legacy Ooky protocol — { tool, arguments } → { result }, kept for
485
+ * Worker-tier compatibility.
486
+ *
487
+ * Returns the same { status, headers, body } shape as serveManifest
488
+ * (body null → respond with an empty body).
489
+ */
490
+ async function handleMcpInvocation(body) {
491
+ const headers = {
492
+ "Content-Type": "application/json; charset=utf-8",
493
+ "Access-Control-Allow-Origin": "*",
494
+ "X-Ooky-Sdk": `${SDK_RUNTIME}/${SDK_VERSION}`,
495
+ };
496
+
497
+ // Standard JSON-RPC 2.0 path (also handles null = unparseable body).
498
+ if (body === null || body === undefined || body.jsonrpc === "2.0" || Array.isArray(body)) {
499
+ const { status, body: rpcBody } = await handleMcpJsonRpc(body ?? null, {
500
+ name: `ooky-${domain.replace(/[^a-z0-9-]/gi, "-")}`,
501
+ version: SDK_VERSION,
502
+ tools: MCP_TOOLS,
503
+ callTool: (name, args) => {
504
+ if (name === "get_brand_info") return getBrandInfo(args);
505
+ throw new McpToolError(`Unknown tool: ${name}`);
506
+ },
507
+ });
508
+ return { status, headers, body: rpcBody };
509
+ }
510
+
511
+ // Legacy { tool, arguments } path.
512
+ if (typeof body !== "object") {
513
+ return { status: 400, headers, body: { error: "JSON body required" } };
514
+ }
515
+ if (!body.tool) {
516
+ return { status: 400, headers, body: { error: 'Missing "tool" field' } };
517
+ }
518
+ if (body.tool !== "get_brand_info") {
519
+ return { status: 404, headers, body: { error: `Unknown tool: ${body.tool}` } };
520
+ }
521
+ try {
522
+ const result = await getBrandInfo(body.arguments);
523
+ return { status: 200, headers, body: { result } };
524
+ } catch (err) {
525
+ return {
526
+ status: 502,
527
+ headers,
528
+ body: { error: err instanceof McpToolError ? err.message : "Tool invocation failed" },
529
+ };
530
+ }
531
+ }
532
+
533
+ /**
534
+ * The in-flight bot-registry refresh, if any. Edge adapters must register
535
+ * this with `event.waitUntil()` — edge runtimes can cancel outstanding
536
+ * fetches once the response returns, which would otherwise abort the
537
+ * refresh kicked off by detectBot on every request.
538
+ */
539
+ function pendingBotRefresh() {
540
+ return botRefreshInFlight;
182
541
  }
183
542
 
184
543
  return {
185
544
  matchPath,
186
545
  detectBot,
546
+ detectReferral,
187
547
  serveManifest,
548
+ handleMcpInvocation,
188
549
  recordEvent,
189
550
  refreshBotRegistry,
551
+ pendingBotRefresh,
190
552
  // exposed for tests / introspection
191
- _options: { apiBase, cdnBase, domain },
553
+ _options: { apiBase, cdnBase, domain, fetchTimeoutMs, manifestCacheTtlMs },
192
554
  };
193
555
  }
194
556
 
@@ -215,6 +577,83 @@ function assertHttpUrl(value, optionName) {
215
577
  return value;
216
578
  }
217
579
 
580
+ function positiveNumber(value, fallback) {
581
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
582
+ }
583
+
584
+ /**
585
+ * Best-effort runtime tag for the `X-Ooky-Sdk` header. We must not import
586
+ * `node:*` or touch `process` unconditionally (this file runs on Vercel Edge
587
+ * verbatim), so everything is feature-detected off globalThis.
588
+ *
589
+ * - "edge" — a Web-runtime marker is present (EdgeRuntime global, or
590
+ * navigator.userAgent reports Cloudflare-Workers/Vercel-Edge).
591
+ * - "node" — process.versions.node exists and no edge marker.
592
+ * - "web" — anything else with a Web Fetch surface but no Node.
593
+ */
594
+ function detectRuntime() {
595
+ try {
596
+ const g = globalThis;
597
+ // Vercel Edge sets a global `EdgeRuntime` ("edge-runtime").
598
+ if (typeof g.EdgeRuntime !== "undefined") return "edge";
599
+ const navUA =
600
+ g.navigator && typeof g.navigator.userAgent === "string" ? g.navigator.userAgent : "";
601
+ if (/Cloudflare-Workers|Vercel-Edge|Deno/i.test(navUA)) return "edge";
602
+ if (g.process && g.process.versions && typeof g.process.versions.node === "string") {
603
+ return "node";
604
+ }
605
+ // Has a Web Fetch surface but isn't Node — generic web/edge worker.
606
+ if (typeof g.fetch === "function") return "web";
607
+ } catch {
608
+ // Fall through to the safe default.
609
+ }
610
+ return "node";
611
+ }
612
+
613
+ /**
614
+ * Section filter for get_brand_info — mirrors the Worker's getBrandInfo
615
+ * switch (worker/src/mcp.js). The public manifest JSON doesn't always carry
616
+ * the Worker's R2 key layout, so when a section's keys are absent we fall
617
+ * back to the full manifest rather than returning an empty object.
618
+ */
619
+ function filterBrandSection(manifest, section) {
620
+ if (!section) return manifest;
621
+ let picked;
622
+ switch (section) {
623
+ case "about":
624
+ picked = { brand: manifest.brand, audience: manifest.audience };
625
+ break;
626
+ case "products":
627
+ picked = { positioning: manifest.positioning, brand: manifest.brand && { name: manifest.brand.name } };
628
+ break;
629
+ case "contact":
630
+ picked = {
631
+ support: manifest.support,
632
+ brand: manifest.brand && { name: manifest.brand.name, website: manifest.brand.website },
633
+ };
634
+ break;
635
+ case "policies":
636
+ picked = { aiGuidelines: manifest.aiGuidelines };
637
+ break;
638
+ default:
639
+ return manifest;
640
+ }
641
+ const hasContent = Object.values(picked).some((v) => v != null);
642
+ return hasContent ? picked : manifest;
643
+ }
644
+
645
+ /**
646
+ * AbortSignal with a deadline, when the runtime supports it (Node ≥18,
647
+ * Cloudflare Workers, Vercel Edge all do). Returns undefined otherwise so
648
+ * fetch falls back to no timeout rather than crashing.
649
+ */
650
+ function timeoutSignal(ms) {
651
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
652
+ return AbortSignal.timeout(ms);
653
+ }
654
+ return undefined;
655
+ }
656
+
218
657
  /**
219
658
  * Tiny stand-in for crypto.randomUUID() that works in every JS runtime.
220
659
  * Returns a 16-char base36 string — collision risk is negligible for events
package/src/edge.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { ookyMiddleware as ookyEdge } from "./next";
@@ -0,0 +1,6 @@
1
+ import type { OokyHandlerOptions } from "./index";
2
+
3
+ /** Express-compatible middleware: `app.use(ookyMiddleware({ apiKey, domain }))`. */
4
+ export function ookyMiddleware(
5
+ options: OokyHandlerOptions
6
+ ): (req: any, res: any, next: (err?: unknown) => void) => Promise<void>;