@endday/search-mcp 1.0.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 +674 -0
- package/README.md +117 -0
- package/README.zh.md +116 -0
- package/data/blocklist.generated.js +2 -0
- package/envs.js +129 -0
- package/index.d.ts +191 -0
- package/index.js +6 -0
- package/mcp/search-mcp.js +8 -0
- package/package.json +71 -0
- package/src/content/extract.impl.js +228 -0
- package/src/content/extract.js +1 -0
- package/src/content/fetch.impl.js +400 -0
- package/src/content/fetch.js +1 -0
- package/src/core/crypto.js +7 -0
- package/src/core/errors.impl.js +52 -0
- package/src/core/errors.js +1 -0
- package/src/core/html.impl.js +69 -0
- package/src/core/html.js +1 -0
- package/src/mcp/config.js +75 -0
- package/src/mcp/format.js +44 -0
- package/src/mcp/index.js +10 -0
- package/src/mcp/local/content.js +26 -0
- package/src/mcp/local/search.js +233 -0
- package/src/mcp/schemas.js +132 -0
- package/src/mcp/server.js +97 -0
- package/src/mcp/tools/content.js +31 -0
- package/src/mcp/tools/jinaContent.js +38 -0
- package/src/mcp/tools/newsSearch.js +22 -0
- package/src/mcp/tools/webSearch.js +57 -0
- package/src/platform/auth.impl.js +166 -0
- package/src/platform/auth.js +1 -0
- package/src/platform/cache.impl.js +166 -0
- package/src/platform/cache.js +1 -0
- package/src/platform/health.impl.js +133 -0
- package/src/platform/health.js +1 -0
- package/src/platform/http.impl.js +108 -0
- package/src/platform/http.js +1 -0
- package/src/platform/logger.impl.js +51 -0
- package/src/platform/logger.js +1 -0
- package/src/platform/metrics.impl.js +43 -0
- package/src/platform/metrics.js +1 -0
- package/src/platform/nodeHttpClient.js +104 -0
- package/src/platform/rateLimit.impl.js +141 -0
- package/src/platform/rateLimit.js +1 -0
- package/src/platform/requestContext.impl.js +10 -0
- package/src/platform/requestContext.js +1 -0
- package/src/platform/session.impl.js +198 -0
- package/src/platform/session.js +1 -0
- package/src/platform/stateKv.impl.js +18 -0
- package/src/platform/stateKv.js +1 -0
- package/src/platform/tasks.impl.js +17 -0
- package/src/platform/tasks.js +1 -0
- package/src/routes/requestParams.impl.js +12 -0
- package/src/routes/requestParams.js +1 -0
- package/src/search/engineRegistry.impl.js +117 -0
- package/src/search/engineRegistry.js +1 -0
- package/src/search/engineRequest.impl.js +377 -0
- package/src/search/engineRequest.js +1 -0
- package/src/search/engineUtils.impl.js +227 -0
- package/src/search/engineUtils.js +1 -0
- package/src/search/engines/baidu.impl.js +145 -0
- package/src/search/engines/baidu.js +2 -0
- package/src/search/engines/bing.impl.js +509 -0
- package/src/search/engines/bing.js +2 -0
- package/src/search/engines/brave.impl.js +223 -0
- package/src/search/engines/brave.js +2 -0
- package/src/search/engines/duckduckgo.impl.js +164 -0
- package/src/search/engines/duckduckgo.js +2 -0
- package/src/search/engines/mojeek.impl.js +115 -0
- package/src/search/engines/mojeek.js +2 -0
- package/src/search/engines/qwant.impl.js +188 -0
- package/src/search/engines/qwant.js +2 -0
- package/src/search/engines/startpage.impl.js +237 -0
- package/src/search/engines/startpage.js +2 -0
- package/src/search/engines/toutiao.impl.js +265 -0
- package/src/search/engines/toutiao.js +2 -0
- package/src/search/engines/yahoo.impl.js +379 -0
- package/src/search/engines/yahoo.js +2 -0
- package/src/search/gateway.impl.js +423 -0
- package/src/search/gateway.js +1 -0
- package/src/search/ranking.impl.js +381 -0
- package/src/search/ranking.js +1 -0
- package/src/search/requestPolicy.impl.js +137 -0
- package/src/search/requestPolicy.js +1 -0
- package/src/search/upstreamSession.impl.js +148 -0
- package/src/search/upstreamSession.js +1 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { env } from "../../envs.js";
|
|
2
|
+
import { ApiError } from "../core/errors.js";
|
|
3
|
+
import { validateSession, getSessionIdFromRequest } from "./session.js";
|
|
4
|
+
import { enforceRateLimit } from "./rateLimit.js";
|
|
5
|
+
|
|
6
|
+
function matchesConfigSet(value, allowedValues) {
|
|
7
|
+
return allowedValues.includes(
|
|
8
|
+
String(value || "").trim().toLowerCase()
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TRUTHY_CONFIG_VALUES = ["1", "true", "yes", "on", "required"];
|
|
13
|
+
|
|
14
|
+
function getBearerToken(request) {
|
|
15
|
+
return request.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getRequestToken(request, paramToken) {
|
|
19
|
+
return getBearerToken(request) || request.headers.get("x-api-key") || paramToken;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isAuthRequired() {
|
|
23
|
+
return !!env.TOKEN || isTruthyConfig(env.AUTH_REQUIRED);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isTruthyConfig(value) {
|
|
27
|
+
return matchesConfigSet(value, TRUTHY_CONFIG_VALUES);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isAuthorizedToken(requestToken) {
|
|
31
|
+
if (!isAuthRequired()) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return !!env.TOKEN && requestToken === env.TOKEN;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Unified authentication: checks token OR session cookie.
|
|
39
|
+
* Returns { authenticated, method } where method is 'token', 'session', or 'none'.
|
|
40
|
+
*/
|
|
41
|
+
export async function authenticateRequest(request, params = {}) {
|
|
42
|
+
// If auth is not required at all, allow immediately
|
|
43
|
+
if (!isAuthRequired()) {
|
|
44
|
+
return { authenticated: true, method: "none" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 1. Check token auth (Authorization header, x-api-key header, or query param)
|
|
48
|
+
const requestToken = getRequestToken(request, params.token);
|
|
49
|
+
if (requestToken && isAuthorizedToken(requestToken)) {
|
|
50
|
+
return { authenticated: true, method: "token", token: requestToken };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2. Check session cookie
|
|
54
|
+
const sessionResult = await validateSession(request);
|
|
55
|
+
if (sessionResult.valid) {
|
|
56
|
+
return {
|
|
57
|
+
authenticated: true,
|
|
58
|
+
method: "session",
|
|
59
|
+
sessionId: sessionResult.sessionId,
|
|
60
|
+
setCookie: sessionResult.setCookie,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 3. Not authenticated - fail closed if AUTH_REQUIRED but no TOKEN configured
|
|
65
|
+
if (isTruthyConfig(env.AUTH_REQUIRED) && !env.TOKEN) {
|
|
66
|
+
throw new ApiError({
|
|
67
|
+
status: 503,
|
|
68
|
+
code: "AUTH_TOKEN_NOT_CONFIGURED",
|
|
69
|
+
category: "configuration",
|
|
70
|
+
message:
|
|
71
|
+
"AUTH_REQUIRED is enabled but TOKEN is not configured. Set TOKEN before using protected local MCP flows.",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { authenticated: false, method: "none" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the rate limit key based on auth method.
|
|
80
|
+
*/
|
|
81
|
+
export function getRateLimitKey(authResult) {
|
|
82
|
+
if (authResult.method === "token") {
|
|
83
|
+
return authResult.token;
|
|
84
|
+
}
|
|
85
|
+
if (authResult.method === "session" && authResult.sessionId) {
|
|
86
|
+
return `session:${authResult.sessionId}`;
|
|
87
|
+
}
|
|
88
|
+
// For unauthenticated, use null (IP-based rate limiting)
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getRequestClientId(request, authResult, paramToken) {
|
|
93
|
+
if (authResult?.method === "session" && authResult.sessionId) {
|
|
94
|
+
return `session:${authResult.sessionId}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (authResult?.method === "token" && authResult.token) {
|
|
98
|
+
return `token:${authResult.token}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const requestToken = getRequestToken(request, paramToken);
|
|
102
|
+
if (requestToken && isAuthorizedToken(requestToken)) {
|
|
103
|
+
return `token:${requestToken}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const sessionId = getSessionIdFromRequest(request);
|
|
107
|
+
if (sessionId) {
|
|
108
|
+
return `session:${sessionId}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const clientIp =
|
|
112
|
+
request.headers.get("cf-connecting-ip") ||
|
|
113
|
+
request.headers.get("x-forwarded-for") ||
|
|
114
|
+
"anonymous";
|
|
115
|
+
|
|
116
|
+
return `ip:${clientIp}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get rate limit token for pre-auth rate limiting.
|
|
121
|
+
* Returns the token if valid, session ID if cookie present, otherwise null (IP-based).
|
|
122
|
+
* Fix #7: Session users get their own bucket, not shared with unauthenticated IP users.
|
|
123
|
+
*/
|
|
124
|
+
export function getRateLimitToken(request, paramToken) {
|
|
125
|
+
if (!isAuthRequired()) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const requestToken = getRequestToken(request, paramToken);
|
|
130
|
+
if (requestToken && isAuthorizedToken(requestToken)) {
|
|
131
|
+
return requestToken;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Peek at session cookie without KV validation - gives session users
|
|
135
|
+
// their own rate limit bucket so they don't share with unauthenticated IPs
|
|
136
|
+
const sessionId = getSessionIdFromRequest(request);
|
|
137
|
+
if (sessionId) {
|
|
138
|
+
return `session:${sessionId}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Fix #8: Unified auth+rate-limit helper to eliminate boilerplate duplication.
|
|
146
|
+
* Rate limits first (prevents brute force), then authenticates.
|
|
147
|
+
* Returns authResult or throws 401/429.
|
|
148
|
+
*/
|
|
149
|
+
export async function requireAuth(request, params = {}) {
|
|
150
|
+
await enforceRateLimit(request, getRateLimitToken(request, params.token));
|
|
151
|
+
|
|
152
|
+
const authResult = await authenticateRequest(request, params);
|
|
153
|
+
|
|
154
|
+
if (!authResult.authenticated) {
|
|
155
|
+
throw new ApiError({
|
|
156
|
+
status: 401,
|
|
157
|
+
code: "UNAUTHORIZED",
|
|
158
|
+
category: "auth",
|
|
159
|
+
message: "Authentication required. Provide a valid token or session cookie.",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return authResult;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export { isAuthRequired, isTruthyConfig, matchesConfigSet, getRequestToken, isAuthorizedToken };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./auth.impl.js";
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { env } from "../../envs.js";
|
|
2
|
+
import { sha256Hex } from "../core/crypto.js";
|
|
3
|
+
import { logWarn } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
const CACHE_PREFIX = "search:v3";
|
|
6
|
+
const EDGE_CACHE_NAMESPACE = "search-edge:v1";
|
|
7
|
+
|
|
8
|
+
function isKvAvailable() {
|
|
9
|
+
return !!env.SEARCH_KV && typeof env.SEARCH_KV.get === "function";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isEdgeCacheAvailable() {
|
|
13
|
+
return typeof caches !== "undefined" && !!caches.default;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function getCacheKey({
|
|
17
|
+
query,
|
|
18
|
+
requested_engines,
|
|
19
|
+
engines,
|
|
20
|
+
language,
|
|
21
|
+
time_range,
|
|
22
|
+
pageno,
|
|
23
|
+
}) {
|
|
24
|
+
const payload = JSON.stringify({
|
|
25
|
+
query,
|
|
26
|
+
requested_engines,
|
|
27
|
+
engines,
|
|
28
|
+
language,
|
|
29
|
+
time_range,
|
|
30
|
+
pageno,
|
|
31
|
+
});
|
|
32
|
+
const hash = await sha256Hex(payload);
|
|
33
|
+
return `${CACHE_PREFIX}:${hash}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function getEdgeCacheKey(searchParams) {
|
|
37
|
+
const key = await getCacheKey(searchParams);
|
|
38
|
+
return `${EDGE_CACHE_NAMESPACE}:${key}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getCachedSearchResponseFromEdge(searchParams) {
|
|
42
|
+
const ttl = Number.parseInt(env.EDGE_CACHE_TTL_SECONDS || "0", 10);
|
|
43
|
+
if (!isEdgeCacheAvailable() || ttl <= 0) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const cacheKey = await getEdgeCacheKey(searchParams);
|
|
49
|
+
const response = await caches.default.match(`https://cache.internal/${cacheKey}`);
|
|
50
|
+
if (!response) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const payload = await response.json();
|
|
55
|
+
if (!payload?.response) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
response: payload.response,
|
|
61
|
+
state: "hit",
|
|
62
|
+
layer: "edge",
|
|
63
|
+
};
|
|
64
|
+
} catch (error) {
|
|
65
|
+
logWarn("cache.edge_read_failed", {}, error);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function setCachedSearchResponseToEdge(searchParams, response) {
|
|
71
|
+
const ttl = Number.parseInt(env.EDGE_CACHE_TTL_SECONDS || "0", 10);
|
|
72
|
+
if (!isEdgeCacheAvailable() || ttl <= 0) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const cacheKey = await getEdgeCacheKey(searchParams);
|
|
78
|
+
const request = new Request(`https://cache.internal/${cacheKey}`);
|
|
79
|
+
const payload = new Response(
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
response,
|
|
82
|
+
}),
|
|
83
|
+
{
|
|
84
|
+
headers: {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
"Cache-Control": `max-age=${ttl}`,
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
await caches.default.put(request, payload);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logWarn("cache.edge_write_failed", {}, error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function getCachedSearchResponse(searchParams) {
|
|
97
|
+
const edgeEntry = await getCachedSearchResponseFromEdge(searchParams);
|
|
98
|
+
if (edgeEntry) {
|
|
99
|
+
return edgeEntry;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ttl = Number.parseInt(env.CACHE_TTL_SECONDS || "0", 10);
|
|
103
|
+
if (!isKvAvailable() || ttl <= 0) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const key = await getCacheKey(searchParams);
|
|
108
|
+
const entry = await env.SEARCH_KV.get(key, "json");
|
|
109
|
+
if (!entry?.response) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
if (entry.freshUntil > now) {
|
|
115
|
+
return {
|
|
116
|
+
response: entry.response,
|
|
117
|
+
state: "hit",
|
|
118
|
+
layer: "kv",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (entry.staleUntil > now) {
|
|
123
|
+
return {
|
|
124
|
+
response: entry.response,
|
|
125
|
+
state: "stale",
|
|
126
|
+
layer: "kv",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function setCachedSearchResponse(searchParams, response) {
|
|
134
|
+
await setCachedSearchResponseToEdge(searchParams, response);
|
|
135
|
+
|
|
136
|
+
const ttl = Number.parseInt(env.CACHE_TTL_SECONDS || "0", 10);
|
|
137
|
+
if (!isKvAvailable() || ttl <= 0) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const staleTtl = Math.max(
|
|
142
|
+
0,
|
|
143
|
+
Number.parseInt(env.STALE_CACHE_TTL_SECONDS || "0", 10)
|
|
144
|
+
);
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
const freshUntil = now + ttl * 1000;
|
|
147
|
+
const staleUntil = freshUntil + staleTtl * 1000;
|
|
148
|
+
const key = await getCacheKey(searchParams);
|
|
149
|
+
await env.SEARCH_KV.put(
|
|
150
|
+
key,
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
response,
|
|
153
|
+
freshUntil,
|
|
154
|
+
staleUntil,
|
|
155
|
+
}),
|
|
156
|
+
{
|
|
157
|
+
expirationTtl: ttl + staleTtl,
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createDeferredCachedSearchResponseWriter(searchParams, response) {
|
|
163
|
+
return async function writeCachedSearchResponse() {
|
|
164
|
+
await setCachedSearchResponse(searchParams, response);
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./cache.impl.js";
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { env } from "../../envs.js";
|
|
2
|
+
import { getStateKv, normalizeExpirationTtl } from "./stateKv.js";
|
|
3
|
+
import { logWarn } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
const HEALTH_PREFIX = "health:v2";
|
|
6
|
+
const memoryHealthState = new Map();
|
|
7
|
+
|
|
8
|
+
export function resetHealthState() {
|
|
9
|
+
memoryHealthState.clear();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function createDefaultEngineState() {
|
|
13
|
+
return {
|
|
14
|
+
consecutiveFailures: 0,
|
|
15
|
+
disabledUntil: 0,
|
|
16
|
+
lastFailureAt: 0,
|
|
17
|
+
lastSuccessAt: 0,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getHealthKey(engineName) {
|
|
22
|
+
return `${HEALTH_PREFIX}:${engineName}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getMemoryEngineState(engineName) {
|
|
26
|
+
if (!memoryHealthState.has(engineName)) {
|
|
27
|
+
memoryHealthState.set(engineName, createDefaultEngineState());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return memoryHealthState.get(engineName);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function getKvEngineState(kv, engineName) {
|
|
34
|
+
try {
|
|
35
|
+
return (await kv.get(getHealthKey(engineName), "json")) || null;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
logWarn("health.kv_read_failed", { engine: engineName }, error);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function persistEngineState(engineName, state) {
|
|
43
|
+
const memoryState = getMemoryEngineState(engineName);
|
|
44
|
+
Object.assign(memoryState, state);
|
|
45
|
+
|
|
46
|
+
const kv = getStateKv();
|
|
47
|
+
if (!kv) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await kv.put(getHealthKey(engineName), JSON.stringify(state), {
|
|
53
|
+
expirationTtl: normalizeExpirationTtl(env.HEALTH_STATE_TTL_SECONDS, 3600),
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logWarn("health.kv_write_failed", { engine: engineName }, error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getEngineState(engineName) {
|
|
61
|
+
const kv = getStateKv();
|
|
62
|
+
const kvState = kv ? await getKvEngineState(kv, engineName) : null;
|
|
63
|
+
|
|
64
|
+
if (kvState) {
|
|
65
|
+
return {
|
|
66
|
+
...createDefaultEngineState(),
|
|
67
|
+
...kvState,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return getMemoryEngineState(engineName);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function prioritizeHealthyEngines(engineNames) {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const healthy = [];
|
|
77
|
+
const degraded = [];
|
|
78
|
+
const engineStates = await Promise.all(
|
|
79
|
+
engineNames.map(async (engineName) => ({
|
|
80
|
+
engineName,
|
|
81
|
+
state: await getEngineState(engineName),
|
|
82
|
+
}))
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
for (const { engineName, state } of engineStates) {
|
|
86
|
+
if (state.disabledUntil > now) {
|
|
87
|
+
degraded.push(engineName);
|
|
88
|
+
} else {
|
|
89
|
+
healthy.push(engineName);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return healthy.length > 0 ? [...healthy, ...degraded] : [...engineNames];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function recordEngineSuccess(engineName) {
|
|
97
|
+
const state = await getEngineState(engineName);
|
|
98
|
+
state.consecutiveFailures = 0;
|
|
99
|
+
state.disabledUntil = 0;
|
|
100
|
+
state.lastSuccessAt = Date.now();
|
|
101
|
+
await persistEngineState(engineName, state);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function recordEngineFailure(engineName) {
|
|
105
|
+
const state = await getEngineState(engineName);
|
|
106
|
+
const failureThreshold = Number.parseInt(
|
|
107
|
+
env.HEALTH_FAILURE_THRESHOLD || "2",
|
|
108
|
+
10
|
|
109
|
+
);
|
|
110
|
+
const cooldownMs =
|
|
111
|
+
Number.parseInt(env.HEALTH_COOLDOWN_SECONDS || "180", 10) * 1000;
|
|
112
|
+
|
|
113
|
+
state.consecutiveFailures += 1;
|
|
114
|
+
state.lastFailureAt = Date.now();
|
|
115
|
+
|
|
116
|
+
if (state.consecutiveFailures >= failureThreshold) {
|
|
117
|
+
state.disabledUntil = Date.now() + cooldownMs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await persistEngineState(engineName, state);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createDeferredEngineSuccessRecorder(engineName) {
|
|
124
|
+
return async function recordSuccess() {
|
|
125
|
+
await recordEngineSuccess(engineName);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createDeferredEngineFailureRecorder(engineName) {
|
|
130
|
+
return async function recordFailure() {
|
|
131
|
+
await recordEngineFailure(engineName);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./health.impl.js";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { env } from "../../envs.js";
|
|
2
|
+
import { normalizeError, toErrorPayload } from "../core/errors.js";
|
|
3
|
+
|
|
4
|
+
const ALLOWED_METHODS = "GET, POST, OPTIONS";
|
|
5
|
+
|
|
6
|
+
function buildServerTimingHeader(engineTimings) {
|
|
7
|
+
return engineTimings
|
|
8
|
+
.map((timing) => `${timing.engine};dur=${timing.duration_ms}`)
|
|
9
|
+
.join(", ");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function buildTierSummary(engineTimings) {
|
|
13
|
+
const tiers = new Set();
|
|
14
|
+
for (const timing of engineTimings) {
|
|
15
|
+
if (timing?.tier) {
|
|
16
|
+
tiers.add(String(timing.tier));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return [...tiers].join(",");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildCorsHeaders(request) {
|
|
24
|
+
const headers = {
|
|
25
|
+
"Access-Control-Allow-Methods": ALLOWED_METHODS,
|
|
26
|
+
"Access-Control-Allow-Headers":
|
|
27
|
+
request.headers.get("Access-Control-Request-Headers") ||
|
|
28
|
+
env.CORS_ALLOWED_HEADERS.join(", "),
|
|
29
|
+
"Access-Control-Max-Age": "86400",
|
|
30
|
+
};
|
|
31
|
+
const origin = request.headers.get("Origin");
|
|
32
|
+
|
|
33
|
+
if (env.CORS_ALLOWED_ORIGINS.includes("*")) {
|
|
34
|
+
headers["Access-Control-Allow-Origin"] = "*";
|
|
35
|
+
return headers;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (origin && env.CORS_ALLOWED_ORIGINS.includes(origin)) {
|
|
39
|
+
headers["Access-Control-Allow-Origin"] = origin;
|
|
40
|
+
headers.Vary = "Origin";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return headers;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function jsonResponse(request, payload, status = 200, headers = {}) {
|
|
47
|
+
return new Response(JSON.stringify(payload), {
|
|
48
|
+
status,
|
|
49
|
+
headers: {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
...buildCorsHeaders(request),
|
|
52
|
+
...headers,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getRequestId(request) {
|
|
58
|
+
return request.headers.get("cf-ray") || crypto.randomUUID();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildSearchResponseHeaders({ requestId, durationMs, meta }) {
|
|
62
|
+
const headers = {
|
|
63
|
+
"X-Search-Request-Id": requestId,
|
|
64
|
+
"X-Search-Duration-Ms": String(durationMs),
|
|
65
|
+
"X-Search-Cache": meta.cache_status,
|
|
66
|
+
"X-Search-Cache-Layer": meta.cache_layer || "none",
|
|
67
|
+
"X-Search-Fallback-Path": meta.fallback_path.join(","),
|
|
68
|
+
"X-Search-Strategy": meta.strategy || "tiered",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (meta.fallback_order.length > 0) {
|
|
72
|
+
headers["X-Search-Fallback-Order"] = meta.fallback_order.join(",");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (meta.engine_timings.length > 0) {
|
|
76
|
+
headers["Server-Timing"] = buildServerTimingHeader(meta.engine_timings);
|
|
77
|
+
const tierSummary = buildTierSummary(meta.engine_timings);
|
|
78
|
+
if (tierSummary) {
|
|
79
|
+
headers["X-Search-Upstream-Tiers"] = tierSummary;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return headers;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createOptionsResponse(request, requestId) {
|
|
87
|
+
return new Response(null, {
|
|
88
|
+
status: 204,
|
|
89
|
+
headers: {
|
|
90
|
+
...buildCorsHeaders(request),
|
|
91
|
+
"X-Search-Request-Id": requestId,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createErrorResponse(request, requestId, error) {
|
|
97
|
+
const normalized = normalizeError(error);
|
|
98
|
+
const status = normalized.status || 500;
|
|
99
|
+
const headers = {
|
|
100
|
+
"X-Search-Request-Id": requestId,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (normalized.details?.retry_after) {
|
|
104
|
+
headers["Retry-After"] = String(normalized.details.retry_after);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return jsonResponse(request, toErrorPayload(normalized), status, headers);
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./http.impl.js";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function pruneUndefined(value) {
|
|
2
|
+
if (!value || typeof value !== "object") {
|
|
3
|
+
return value;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const result = {};
|
|
7
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
8
|
+
if (entry !== undefined) {
|
|
9
|
+
result[key] = entry;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function write(level, event, fields = {}, error, runtimeContext) {
|
|
17
|
+
const request = runtimeContext?.request;
|
|
18
|
+
const url = request ? new URL(request.url) : null;
|
|
19
|
+
const payload = pruneUndefined({
|
|
20
|
+
level,
|
|
21
|
+
event,
|
|
22
|
+
request_id: request?.headers?.get("cf-ray") || fields.request_id,
|
|
23
|
+
method: request?.method,
|
|
24
|
+
path: url?.pathname,
|
|
25
|
+
...fields,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (level === "error") {
|
|
29
|
+
console.error(JSON.stringify(payload), error || "");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (level === "warn") {
|
|
34
|
+
console.warn(JSON.stringify(payload), error || "");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(JSON.stringify(payload), error || "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function logInfo(event, fields = {}, runtimeContext) {
|
|
42
|
+
write("info", event, fields, undefined, runtimeContext);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function logWarn(event, fields = {}, error, runtimeContext) {
|
|
46
|
+
write("warn", event, fields, error, runtimeContext);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function logError(event, fields = {}, error, runtimeContext) {
|
|
50
|
+
write("error", event, fields, error, runtimeContext);
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./logger.impl.js";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
function getStore(runtimeContext) {
|
|
2
|
+
if (!runtimeContext) {
|
|
3
|
+
return null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
if (!runtimeContext.metrics) {
|
|
7
|
+
runtimeContext.metrics = {
|
|
8
|
+
counters: [],
|
|
9
|
+
timings: [],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return runtimeContext.metrics;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function recordMetric(runtimeContext, name, fields = {}) {
|
|
17
|
+
const store = getStore(runtimeContext);
|
|
18
|
+
if (!store) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
store.counters.push({
|
|
23
|
+
name,
|
|
24
|
+
...fields,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function recordTiming(runtimeContext, name, durationMs, fields = {}) {
|
|
29
|
+
const store = getStore(runtimeContext);
|
|
30
|
+
if (!store) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
store.timings.push({
|
|
35
|
+
name,
|
|
36
|
+
duration_ms: durationMs,
|
|
37
|
+
...fields,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readRecordedMetrics(runtimeContext) {
|
|
42
|
+
return runtimeContext?.metrics || { counters: [], timings: [] };
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./metrics.impl.js";
|