@usegraft/content-api 0.0.0-canary-20260901211203 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -42
- package/dist/index.js +14 -108
- package/package.json +3 -5
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
import { ContentIndexReader } from '@usegraft/
|
|
1
|
+
import { ContentIndexReader } from '@usegraft/db';
|
|
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
|
-
}
|
|
10
4
|
interface ContentApiHandlerOptions {
|
|
11
5
|
/** Collection names this endpoint may expose. */
|
|
12
6
|
collections: readonly string[];
|
|
@@ -14,50 +8,18 @@ interface ContentApiHandlerOptions {
|
|
|
14
8
|
branch: string;
|
|
15
9
|
/** Reader owned by the caller. The handler never closes it. */
|
|
16
10
|
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[] | "*";
|
|
46
11
|
}
|
|
47
12
|
interface ContentApiReaderOptions {
|
|
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
|
-
*/
|
|
13
|
+
/** Full API base URL, for example https://cms.example.com/api/content/v1. */
|
|
53
14
|
endpoint: string | URL;
|
|
54
15
|
/** Fetch implementation for non-browser runtimes, tests, or instrumentation. */
|
|
55
16
|
fetch?: typeof globalThis.fetch;
|
|
56
17
|
/** Static headers sent with every request, such as Authorization. */
|
|
57
18
|
headers?: Record<string, string>;
|
|
58
19
|
}
|
|
20
|
+
/** Create the read-only Web-standard handler mounted at /api/content/v1. */
|
|
59
21
|
declare function createContentApiHandler(options: ContentApiHandlerOptions): ContentApiHandler;
|
|
60
22
|
/** Create a ContentIndexReader that reads a remote /api/content/v1 endpoint. */
|
|
61
23
|
declare function createContentApiReader(options: ContentApiReaderOptions): ContentIndexReader;
|
|
62
24
|
|
|
63
|
-
export { type ContentApiHandlerOptions, type
|
|
25
|
+
export { type ContentApiHandlerOptions, type ContentApiReaderOptions, createContentApiHandler, createContentApiReader };
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
3
|
-
ErrorCodes,
|
|
4
|
-
GraftError,
|
|
5
|
-
rateIdentity
|
|
6
|
-
} from "@usegraft/contracts";
|
|
2
|
+
import { ErrorCodes, GraftError } from "@usegraft/contracts";
|
|
7
3
|
var CONTENT_API_BASE = "/api/content/v1";
|
|
8
4
|
var MAX_QUERY_LIMIT = 500;
|
|
9
5
|
var JSON_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
@@ -143,14 +139,6 @@ function json(value, status = 200, extra) {
|
|
|
143
139
|
headers.set("content-type", JSON_CONTENT_TYPE);
|
|
144
140
|
return new Response(JSON.stringify(value), { status, headers });
|
|
145
141
|
}
|
|
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
|
-
}
|
|
154
142
|
function statusFor(error) {
|
|
155
143
|
switch (error.code) {
|
|
156
144
|
case "COLLECTION_NOT_FOUND":
|
|
@@ -160,8 +148,6 @@ function statusFor(error) {
|
|
|
160
148
|
return 405;
|
|
161
149
|
case "INPUT_VALIDATION_FAILED":
|
|
162
150
|
return 400;
|
|
163
|
-
case "RATE_LIMITED":
|
|
164
|
-
return 429;
|
|
165
151
|
default:
|
|
166
152
|
return 500;
|
|
167
153
|
}
|
|
@@ -213,54 +199,10 @@ function assertNoBranchOverride(url) {
|
|
|
213
199
|
);
|
|
214
200
|
}
|
|
215
201
|
}
|
|
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
|
-
}
|
|
246
202
|
function createContentApiHandler(options) {
|
|
247
203
|
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;
|
|
250
204
|
return async (request) => {
|
|
251
205
|
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
|
-
}
|
|
264
206
|
try {
|
|
265
207
|
const route = url.pathname === `${CONTENT_API_BASE}/documents` ? "documents" : url.pathname === `${CONTENT_API_BASE}/search` ? "search" : void 0;
|
|
266
208
|
if (route === void 0) {
|
|
@@ -279,21 +221,6 @@ function createContentApiHandler(options) {
|
|
|
279
221
|
details: { method: request.method }
|
|
280
222
|
});
|
|
281
223
|
}
|
|
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
|
-
}
|
|
297
224
|
assertNoBranchOverride(url);
|
|
298
225
|
const collection = requiredParam(url, "collection");
|
|
299
226
|
if (!collections.has(collection)) {
|
|
@@ -322,7 +249,7 @@ function createContentApiHandler(options) {
|
|
|
322
249
|
offset: parseOffset(url),
|
|
323
250
|
branch: options.branch
|
|
324
251
|
});
|
|
325
|
-
return json({ rows: rows.map(toWireRow) }
|
|
252
|
+
return json({ rows: rows.map(toWireRow) });
|
|
326
253
|
}
|
|
327
254
|
const query = requiredParam(url, "query");
|
|
328
255
|
const hits = await options.index.searchContent({
|
|
@@ -331,52 +258,31 @@ function createContentApiHandler(options) {
|
|
|
331
258
|
limit,
|
|
332
259
|
branch: options.branch
|
|
333
260
|
});
|
|
334
|
-
return json(
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
},
|
|
344
|
-
200,
|
|
345
|
-
cors
|
|
346
|
-
);
|
|
261
|
+
return json({
|
|
262
|
+
hits: hits.map(
|
|
263
|
+
({ row, rank, snippet }) => ({
|
|
264
|
+
row: toWireRow(row),
|
|
265
|
+
rank,
|
|
266
|
+
snippet
|
|
267
|
+
})
|
|
268
|
+
)
|
|
269
|
+
});
|
|
347
270
|
} catch (error) {
|
|
348
271
|
const graftError = error instanceof GraftError ? error : protocolError(
|
|
349
272
|
`Content API failed to read its index: ${error instanceof Error ? error.message : String(error)}`
|
|
350
273
|
);
|
|
351
|
-
const extra = {
|
|
352
|
-
return json(
|
|
353
|
-
graftError.toJSON(),
|
|
354
|
-
statusFor(graftError),
|
|
355
|
-
Object.keys(extra).length > 0 ? extra : void 0
|
|
356
|
-
);
|
|
274
|
+
const extra = graftError.code === "METHOD_NOT_ALLOWED" ? { allow: "GET" } : void 0;
|
|
275
|
+
return json(graftError.toJSON(), statusFor(graftError), extra);
|
|
357
276
|
}
|
|
358
277
|
};
|
|
359
278
|
}
|
|
360
279
|
function normalizeEndpoint(endpoint) {
|
|
361
|
-
const url =
|
|
280
|
+
const url = new URL(endpoint.toString());
|
|
362
281
|
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
363
282
|
url.search = "";
|
|
364
283
|
url.hash = "";
|
|
365
284
|
return url;
|
|
366
285
|
}
|
|
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
|
-
}
|
|
380
286
|
function createContentApiReader(options) {
|
|
381
287
|
const endpoint = normalizeEndpoint(options.endpoint);
|
|
382
288
|
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": "0.2.0",
|
|
4
4
|
"description": "A versioned read-only HTTP transport for Graft's authored content index.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -37,10 +37,8 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@usegraft/contracts": "0.
|
|
41
|
-
|
|
42
|
-
"devDependencies": {
|
|
43
|
-
"@usegraft/db": "0.0.0-canary-20260901211203"
|
|
40
|
+
"@usegraft/contracts": "0.2.0",
|
|
41
|
+
"@usegraft/db": "0.2.0"
|
|
44
42
|
},
|
|
45
43
|
"engines": {
|
|
46
44
|
"node": ">=22.16"
|