@usegraft/content-api 0.2.0 → 1.0.0-beta.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/dist/index.d.ts +42 -4
- package/dist/index.js +108 -14
- package/package.json +5 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { ContentIndexReader } from '@usegraft/
|
|
1
|
+
import { ContentIndexReader } from '@usegraft/contracts';
|
|
2
2
|
|
|
3
3
|
type ContentApiHandler = (request: Request) => Promise<Response>;
|
|
4
|
+
interface ContentApiRateLimit {
|
|
5
|
+
/** Requests allowed per identity per window. */
|
|
6
|
+
limit: number;
|
|
7
|
+
/** Window length in seconds. Also the `Retry-After` value on a refusal. */
|
|
8
|
+
windowSeconds: number;
|
|
9
|
+
}
|
|
4
10
|
interface ContentApiHandlerOptions {
|
|
5
11
|
/** Collection names this endpoint may expose. */
|
|
6
12
|
collections: readonly string[];
|
|
@@ -8,18 +14,50 @@ interface ContentApiHandlerOptions {
|
|
|
8
14
|
branch: string;
|
|
9
15
|
/** Reader owned by the caller. The handler never closes it. */
|
|
10
16
|
index: ContentIndexReader;
|
|
17
|
+
/**
|
|
18
|
+
* Per-identity backstop. Omitted means unlimited, which is the right default
|
|
19
|
+
* for a handler mounted behind something that already has a limiter — but a
|
|
20
|
+
* mount facing the open internet wants one, because these routes run database
|
|
21
|
+
* listings and full-text searches for callers this handler never
|
|
22
|
+
* authenticates.
|
|
23
|
+
*/
|
|
24
|
+
rateLimit?: ContentApiRateLimit;
|
|
25
|
+
/**
|
|
26
|
+
* How many proxies in front of this handler are ours. Forwarded to
|
|
27
|
+
* `rateIdentity`; zero (the default) means `x-forwarded-for` is never read
|
|
28
|
+
* and every unidentified caller shares one bucket.
|
|
29
|
+
*/
|
|
30
|
+
trustedProxyHops?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Origins allowed to read responses from a browser.
|
|
33
|
+
*
|
|
34
|
+
* Omitted means no CORS headers at all, which is same-origin only — the
|
|
35
|
+
* correct default, because publishing an endpoint to other origins is a
|
|
36
|
+
* decision for whoever deploys it and not one a library should make on their
|
|
37
|
+
* behalf. `@usegraft/sdk-react` needs this set whenever the app and the
|
|
38
|
+
* content API are on different origins, which is the ordinary case.
|
|
39
|
+
*
|
|
40
|
+
* An explicit list is echoed back per request (with `Vary: Origin`, so a
|
|
41
|
+
* cache cannot serve one origin's response to another). `"*"` allows any
|
|
42
|
+
* origin and is reasonable for content that is public anyway — which this
|
|
43
|
+
* handler's content is, since it authenticates nobody.
|
|
44
|
+
*/
|
|
45
|
+
allowedOrigins?: readonly string[] | "*";
|
|
11
46
|
}
|
|
12
47
|
interface ContentApiReaderOptions {
|
|
13
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* API base URL: absolute (https://cms.example.com/api/content/v1), or a
|
|
50
|
+
* same-origin path (/api/content/v1) when running in a browser, where it
|
|
51
|
+
* resolves against the page origin.
|
|
52
|
+
*/
|
|
14
53
|
endpoint: string | URL;
|
|
15
54
|
/** Fetch implementation for non-browser runtimes, tests, or instrumentation. */
|
|
16
55
|
fetch?: typeof globalThis.fetch;
|
|
17
56
|
/** Static headers sent with every request, such as Authorization. */
|
|
18
57
|
headers?: Record<string, string>;
|
|
19
58
|
}
|
|
20
|
-
/** Create the read-only Web-standard handler mounted at /api/content/v1. */
|
|
21
59
|
declare function createContentApiHandler(options: ContentApiHandlerOptions): ContentApiHandler;
|
|
22
60
|
/** Create a ContentIndexReader that reads a remote /api/content/v1 endpoint. */
|
|
23
61
|
declare function createContentApiReader(options: ContentApiReaderOptions): ContentIndexReader;
|
|
24
62
|
|
|
25
|
-
export { type ContentApiHandlerOptions, type ContentApiReaderOptions, createContentApiHandler, createContentApiReader };
|
|
63
|
+
export { type ContentApiHandlerOptions, type ContentApiRateLimit, type ContentApiReaderOptions, createContentApiHandler, createContentApiReader };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ErrorCodes,
|
|
4
|
+
GraftError,
|
|
5
|
+
rateIdentity
|
|
6
|
+
} from "@usegraft/contracts";
|
|
3
7
|
var CONTENT_API_BASE = "/api/content/v1";
|
|
4
8
|
var MAX_QUERY_LIMIT = 500;
|
|
5
9
|
var JSON_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
@@ -139,6 +143,14 @@ function json(value, status = 200, extra) {
|
|
|
139
143
|
headers.set("content-type", JSON_CONTENT_TYPE);
|
|
140
144
|
return new Response(JSON.stringify(value), { status, headers });
|
|
141
145
|
}
|
|
146
|
+
function responseHeadersFor(error) {
|
|
147
|
+
if (error.code === "METHOD_NOT_ALLOWED") return { allow: "GET" };
|
|
148
|
+
if (error.code === "RATE_LIMITED") {
|
|
149
|
+
const retryAfter = error.details?.retryAfter;
|
|
150
|
+
if (typeof retryAfter === "number") return { "retry-after": String(retryAfter) };
|
|
151
|
+
}
|
|
152
|
+
return void 0;
|
|
153
|
+
}
|
|
142
154
|
function statusFor(error) {
|
|
143
155
|
switch (error.code) {
|
|
144
156
|
case "COLLECTION_NOT_FOUND":
|
|
@@ -148,6 +160,8 @@ function statusFor(error) {
|
|
|
148
160
|
return 405;
|
|
149
161
|
case "INPUT_VALIDATION_FAILED":
|
|
150
162
|
return 400;
|
|
163
|
+
case "RATE_LIMITED":
|
|
164
|
+
return 429;
|
|
151
165
|
default:
|
|
152
166
|
return 500;
|
|
153
167
|
}
|
|
@@ -199,10 +213,54 @@ function assertNoBranchOverride(url) {
|
|
|
199
213
|
);
|
|
200
214
|
}
|
|
201
215
|
}
|
|
216
|
+
function createRateLimiter(limit, windowSeconds) {
|
|
217
|
+
const windowMs = windowSeconds * 1e3;
|
|
218
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
219
|
+
const SWEEP_THRESHOLD = 4096;
|
|
220
|
+
return (identity, now) => {
|
|
221
|
+
if (buckets.size >= SWEEP_THRESHOLD) {
|
|
222
|
+
for (const [key, bucket2] of buckets) {
|
|
223
|
+
if (bucket2.resetAt <= now) buckets.delete(key);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const bucket = buckets.get(identity);
|
|
227
|
+
if (bucket === void 0 || bucket.resetAt <= now) {
|
|
228
|
+
buckets.set(identity, { count: 1, resetAt: now + windowMs });
|
|
229
|
+
return void 0;
|
|
230
|
+
}
|
|
231
|
+
bucket.count += 1;
|
|
232
|
+
if (bucket.count <= limit) return void 0;
|
|
233
|
+
return Math.max(1, Math.ceil((bucket.resetAt - now) / 1e3));
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function corsHeaders(request, allowed) {
|
|
237
|
+
if (allowed === void 0) return void 0;
|
|
238
|
+
const origin = request.headers.get("origin");
|
|
239
|
+
if (origin === null) return void 0;
|
|
240
|
+
if (allowed === "*") {
|
|
241
|
+
return { "access-control-allow-origin": "*" };
|
|
242
|
+
}
|
|
243
|
+
if (!allowed.includes(origin)) return void 0;
|
|
244
|
+
return { "access-control-allow-origin": origin, vary: "Origin" };
|
|
245
|
+
}
|
|
202
246
|
function createContentApiHandler(options) {
|
|
203
247
|
const collections = new Set(options.collections);
|
|
248
|
+
const consume = options.rateLimit === void 0 ? void 0 : createRateLimiter(options.rateLimit.limit, options.rateLimit.windowSeconds);
|
|
249
|
+
const trustedProxyHops = options.trustedProxyHops ?? 0;
|
|
204
250
|
return async (request) => {
|
|
205
251
|
const url = new URL(request.url);
|
|
252
|
+
const cors = corsHeaders(request, options.allowedOrigins);
|
|
253
|
+
if (request.method === "OPTIONS" && cors !== void 0) {
|
|
254
|
+
return new Response(null, {
|
|
255
|
+
status: 204,
|
|
256
|
+
headers: {
|
|
257
|
+
...cors,
|
|
258
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
259
|
+
"access-control-allow-headers": request.headers.get("access-control-request-headers") ?? "authorization,content-type",
|
|
260
|
+
"access-control-max-age": "86400"
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
206
264
|
try {
|
|
207
265
|
const route = url.pathname === `${CONTENT_API_BASE}/documents` ? "documents" : url.pathname === `${CONTENT_API_BASE}/search` ? "search" : void 0;
|
|
208
266
|
if (route === void 0) {
|
|
@@ -221,6 +279,21 @@ function createContentApiHandler(options) {
|
|
|
221
279
|
details: { method: request.method }
|
|
222
280
|
});
|
|
223
281
|
}
|
|
282
|
+
if (consume !== void 0 && options.rateLimit !== void 0) {
|
|
283
|
+
const retryAfter = consume(rateIdentity(request, trustedProxyHops), Date.now());
|
|
284
|
+
if (retryAfter !== void 0) {
|
|
285
|
+
throw new GraftError({
|
|
286
|
+
code: "RATE_LIMITED",
|
|
287
|
+
message: `This content endpoint allows ${options.rateLimit.limit} requests per ${options.rateLimit.windowSeconds}s per caller.`,
|
|
288
|
+
fix: `Wait ${retryAfter}s \u2014 the Retry-After header says how long \u2014 then retry. Cache reads at your CDN if you need a higher sustained rate.`,
|
|
289
|
+
details: {
|
|
290
|
+
limit: options.rateLimit.limit,
|
|
291
|
+
windowSeconds: options.rateLimit.windowSeconds,
|
|
292
|
+
retryAfter
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
224
297
|
assertNoBranchOverride(url);
|
|
225
298
|
const collection = requiredParam(url, "collection");
|
|
226
299
|
if (!collections.has(collection)) {
|
|
@@ -249,7 +322,7 @@ function createContentApiHandler(options) {
|
|
|
249
322
|
offset: parseOffset(url),
|
|
250
323
|
branch: options.branch
|
|
251
324
|
});
|
|
252
|
-
return json({ rows: rows.map(toWireRow) });
|
|
325
|
+
return json({ rows: rows.map(toWireRow) }, 200, cors);
|
|
253
326
|
}
|
|
254
327
|
const query = requiredParam(url, "query");
|
|
255
328
|
const hits = await options.index.searchContent({
|
|
@@ -258,31 +331,52 @@ function createContentApiHandler(options) {
|
|
|
258
331
|
limit,
|
|
259
332
|
branch: options.branch
|
|
260
333
|
});
|
|
261
|
-
return json(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
row
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
334
|
+
return json(
|
|
335
|
+
{
|
|
336
|
+
hits: hits.map(
|
|
337
|
+
({ row, rank, snippet }) => ({
|
|
338
|
+
row: toWireRow(row),
|
|
339
|
+
rank,
|
|
340
|
+
snippet
|
|
341
|
+
})
|
|
342
|
+
)
|
|
343
|
+
},
|
|
344
|
+
200,
|
|
345
|
+
cors
|
|
346
|
+
);
|
|
270
347
|
} catch (error) {
|
|
271
348
|
const graftError = error instanceof GraftError ? error : protocolError(
|
|
272
349
|
`Content API failed to read its index: ${error instanceof Error ? error.message : String(error)}`
|
|
273
350
|
);
|
|
274
|
-
const extra =
|
|
275
|
-
return json(
|
|
351
|
+
const extra = { ...responseHeadersFor(graftError), ...cors };
|
|
352
|
+
return json(
|
|
353
|
+
graftError.toJSON(),
|
|
354
|
+
statusFor(graftError),
|
|
355
|
+
Object.keys(extra).length > 0 ? extra : void 0
|
|
356
|
+
);
|
|
276
357
|
}
|
|
277
358
|
};
|
|
278
359
|
}
|
|
279
360
|
function normalizeEndpoint(endpoint) {
|
|
280
|
-
const url = new URL(endpoint
|
|
361
|
+
const url = endpoint instanceof URL ? new URL(endpoint) : resolveEndpointUrl(endpoint);
|
|
281
362
|
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
282
363
|
url.search = "";
|
|
283
364
|
url.hash = "";
|
|
284
365
|
return url;
|
|
285
366
|
}
|
|
367
|
+
function resolveEndpointUrl(endpoint) {
|
|
368
|
+
const base = globalThis.location?.href;
|
|
369
|
+
try {
|
|
370
|
+
return base === void 0 ? new URL(endpoint) : new URL(endpoint, base);
|
|
371
|
+
} catch {
|
|
372
|
+
throw new GraftError({
|
|
373
|
+
code: "CONFIG_INVALID",
|
|
374
|
+
message: `\`endpoint\` is not a valid URL: ${endpoint}`,
|
|
375
|
+
fix: base === void 0 ? "Outside a browser there is no page origin to resolve a relative path against. Pass an absolute endpoint, e.g. https://cms.example.com/api/content/v1." : "Pass an absolute endpoint (https://cms.example.com/api/content/v1) or a same-origin path (/api/content/v1).",
|
|
376
|
+
details: { endpoint, base }
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
286
380
|
function createContentApiReader(options) {
|
|
287
381
|
const endpoint = normalizeEndpoint(options.endpoint);
|
|
288
382
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usegraft/content-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-beta.0",
|
|
4
4
|
"description": "A versioned read-only HTTP transport for Graft's authored content index.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -37,8 +37,10 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@usegraft/contracts": "0.
|
|
41
|
-
|
|
40
|
+
"@usegraft/contracts": "1.0.0-beta.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@usegraft/db": "1.0.0-beta.0"
|
|
42
44
|
},
|
|
43
45
|
"engines": {
|
|
44
46
|
"node": ">=22.16"
|