blume 1.5.1 → 1.5.2
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/CHANGELOG.md +17 -0
- package/dist/cli/index.js +340 -132
- package/dist/cli/index.js.map +21 -20
- package/dist/types/ai/ask-context.d.ts +78 -0
- package/dist/types/core/config-input.d.ts +53 -1
- package/dist/types/core/data.d.ts +16 -0
- package/dist/types/core/open-in-chat.d.ts +9 -0
- package/dist/types/core/schema.d.ts +48 -1
- package/dist/types/core/types.d.ts +10 -3
- package/dist/types/openapi/references.d.ts +9 -0
- package/dist/types/search/orama-index.d.ts +70 -0
- package/docs/advanced/api-reference.mdx +67 -5
- package/docs/configuration/ai.mdx +35 -0
- package/docs/configuration/index.mdx +13 -1
- package/docs/configuration/search.mdx +4 -4
- package/docs/reference/cli.mdx +1 -1
- package/package.json +1 -1
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/ask-context.ts +51 -11
- package/src/ai/mcp/data.ts +3 -2
- package/src/ai/mcp/server.ts +3 -2
- package/src/assets/icon-dark.png +0 -0
- package/src/astro/generate.ts +162 -13
- package/src/astro/templates.ts +63 -11
- package/src/components/content/AccordionItem.astro +4 -0
- package/src/components/content/Update.astro +3 -0
- package/src/components/islands/AskAI.astro +6 -0
- package/src/components/islands/ask-ai.tsx +39 -9
- package/src/components/layout/Analytics.astro +9 -1
- package/src/components/layout/Favicon.astro +29 -8
- package/src/components/layout/Header.astro +2 -2
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/PageActions.astro +120 -78
- package/src/components/layout/PageFeedback.astro +12 -3
- package/src/components/layout/PageLayout.astro +7 -2
- package/src/components/layout/ReferenceLayout.astro +10 -8
- package/src/components/layout/RootLayout.astro +151 -120
- package/src/components/layout/Search.astro +41 -26
- package/src/components/layout/drawer-inert.ts +10 -5
- package/src/components/layout/head-scripts.ts +34 -16
- package/src/components/layout/nav-utils.ts +34 -15
- package/src/components/layout/search/orama.ts +3 -2
- package/src/components/openapi/AsyncApiOperation.astro +22 -7
- package/src/components/openapi/MessageComposer.astro +238 -0
- package/src/components/openapi/Operation.astro +26 -12
- package/src/components/openapi/PanelTabs.astro +7 -0
- package/src/components/openapi/Playground.astro +320 -0
- package/src/components/openapi/RequestPanel.astro +1 -0
- package/src/components/openapi/async-snippets.ts +20 -7
- package/src/components/openapi/async.ts +13 -2
- package/src/components/openapi/message-composer.ts +242 -0
- package/src/components/openapi/message-model.ts +108 -0
- package/src/components/openapi/message.ts +153 -0
- package/src/components/openapi/operation-model.ts +260 -0
- package/src/components/openapi/playground-client.ts +486 -0
- package/src/components/openapi/playground-schema.ts +109 -0
- package/src/components/openapi/request.ts +287 -0
- package/src/components/openapi/security.ts +0 -56
- package/src/components/openapi/snippets.ts +23 -136
- package/src/components/openapi/validate-json.ts +144 -0
- package/src/components/openapi/ws-client.ts +194 -0
- package/src/core/config-input.ts +66 -0
- package/src/core/content-assets.ts +66 -15
- package/src/core/data.ts +13 -0
- package/src/core/last-modified.ts +28 -3
- package/src/core/links.ts +30 -4
- package/src/core/navigation.ts +26 -1
- package/src/core/open-in-chat.ts +17 -0
- package/src/core/schema.ts +59 -0
- package/src/core/server-features.ts +11 -0
- package/src/core/sources/normalize.ts +10 -2
- package/src/core/types.ts +10 -3
- package/src/openapi/model.ts +7 -0
- package/src/openapi/proxy.ts +217 -0
- package/src/openapi/references.ts +8 -0
- package/src/openapi/source.ts +13 -0
- package/src/registry/eject.ts +4 -5
- package/src/search/orama-index.ts +109 -36
- package/src/theme/entry.ts +6 -15
package/src/core/schema.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizeXHandle } from "../seo/x-handle.ts";
|
|
|
8
8
|
import { FONT_SLUGS, isFontSlug } from "../theme/fonts.ts";
|
|
9
9
|
import { normalizeBasePath } from "./base-path.ts";
|
|
10
10
|
import { uiLocaleOverridesSchema } from "./i18n-ui.ts";
|
|
11
|
+
import { openInChatProviders } from "./open-in-chat.ts";
|
|
11
12
|
import type { ContentSource } from "./sources/types.ts";
|
|
12
13
|
import { isStandardSchema } from "./standard-schema.ts";
|
|
13
14
|
import type { StandardSchema } from "./standard-schema.ts";
|
|
@@ -813,6 +814,19 @@ const aiConfigSchema = z.strictObject({
|
|
|
813
814
|
instructions: z.string().trim().min(1).optional(),
|
|
814
815
|
model: z.string().default("openai/gpt-5.5"),
|
|
815
816
|
provider: z.enum(askAiProviders).default("gateway"),
|
|
817
|
+
// How much documentation each question carries. Injected characters are
|
|
818
|
+
// the dominant term in time-to-first-token on a self-hosted backend, so
|
|
819
|
+
// these trade recall for latency. No zod defaults here: only what the
|
|
820
|
+
// user set reaches the generated (and ejected) endpoint, so omitted
|
|
821
|
+
// fields keep tracking the installed package's built-in defaults in
|
|
822
|
+
// `ai/ask-context.ts` instead of pinning today's numbers as literals.
|
|
823
|
+
retrieval: z
|
|
824
|
+
.strictObject({
|
|
825
|
+
contextBudget: z.number().int().positive().optional(),
|
|
826
|
+
excerptChars: z.number().int().positive().optional(),
|
|
827
|
+
maxResults: z.number().int().positive().optional(),
|
|
828
|
+
})
|
|
829
|
+
.optional(),
|
|
816
830
|
// Empty-state prompts shown before the first question. Each renders as a
|
|
817
831
|
// clickable suggestion; `icon` is an optional Lucide name beside it.
|
|
818
832
|
suggestions: z
|
|
@@ -877,6 +891,28 @@ const aiConfigSchema = z.strictObject({
|
|
|
877
891
|
.default({}),
|
|
878
892
|
/** Expose the docs as an MCP server for connecting agents. */
|
|
879
893
|
mcp: mcpConfigSchema.prefault({}),
|
|
894
|
+
/**
|
|
895
|
+
* The "Open in chat" page action. `true` (the default) lists every
|
|
896
|
+
* provider, `false` hides the action entirely, and an array of provider
|
|
897
|
+
* keys shows just that subset, in the given order. Normalized to the
|
|
898
|
+
* provider list so consumers read a plain array.
|
|
899
|
+
*/
|
|
900
|
+
openInChat: z
|
|
901
|
+
.union([
|
|
902
|
+
z.boolean(),
|
|
903
|
+
z
|
|
904
|
+
.array(z.enum(openInChatProviders))
|
|
905
|
+
.refine((value) => new Set(value).size === value.length, {
|
|
906
|
+
message: "ai.openInChat must not repeat a provider.",
|
|
907
|
+
}),
|
|
908
|
+
])
|
|
909
|
+
.default(true)
|
|
910
|
+
.transform((value) => {
|
|
911
|
+
if (isBoolean(value)) {
|
|
912
|
+
return value ? [...openInChatProviders] : [];
|
|
913
|
+
}
|
|
914
|
+
return value;
|
|
915
|
+
}),
|
|
880
916
|
/**
|
|
881
917
|
* Publish Agent Skills for discovery: a directory (resolved against the
|
|
882
918
|
* project root) whose subdirectories each hold a `SKILL.md`. The build
|
|
@@ -947,6 +983,8 @@ const navigationConfigSchema = z.strictObject({
|
|
|
947
983
|
|
|
948
984
|
export type AskAiProvider = (typeof askAiProviders)[number];
|
|
949
985
|
export type AskAiConfig = NonNullable<z.infer<typeof aiConfigSchema>["ask"]>;
|
|
986
|
+
export { openInChatProviders } from "./open-in-chat.ts";
|
|
987
|
+
export type { OpenInChatProvider } from "./open-in-chat.ts";
|
|
950
988
|
|
|
951
989
|
// Reader-facing "Export" page action (PDF via print, EPUB via client-side
|
|
952
990
|
// generation). Off by default. Accepts a shorthand boolean to toggle both
|
|
@@ -1553,6 +1591,27 @@ const referenceConfigSchema = (defaults: {
|
|
|
1553
1591
|
enabled: z.boolean().default(false),
|
|
1554
1592
|
/** Start nested schema rows expanded rather than collapsed (Blume renderer). */
|
|
1555
1593
|
expandSchemas: z.boolean().default(false),
|
|
1594
|
+
/**
|
|
1595
|
+
* The interactive "Try it" panel on operation pages (Blume renderer). On by
|
|
1596
|
+
* default; `false` hides it. The object form keeps it on and sets `proxy`,
|
|
1597
|
+
* the CORS escape hatch the OpenAPI Send button routes requests through: a
|
|
1598
|
+
* proxy URL, or `true` for the built-in `/_api-proxy` endpoint (which
|
|
1599
|
+
* requires `deployment.output: "server"`). Booleans normalize to the object
|
|
1600
|
+
* shape so consumers read `{ enabled, proxy }` directly. `proxy` is
|
|
1601
|
+
* OpenAPI-only — an event composer's WebSocket connect is direct.
|
|
1602
|
+
*/
|
|
1603
|
+
playground: z
|
|
1604
|
+
.union([
|
|
1605
|
+
z.boolean(),
|
|
1606
|
+
z.strictObject({
|
|
1607
|
+
enabled: z.boolean().default(true),
|
|
1608
|
+
proxy: z.union([z.boolean(), z.string()]).default(false),
|
|
1609
|
+
}),
|
|
1610
|
+
])
|
|
1611
|
+
.default(true)
|
|
1612
|
+
.transform((value) =>
|
|
1613
|
+
isBoolean(value) ? { enabled: value, proxy: false } : value
|
|
1614
|
+
),
|
|
1556
1615
|
/** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
|
|
1557
1616
|
renderer: z.enum(["blume", "scalar"]).default("blume"),
|
|
1558
1617
|
/** Where the reference mounts. */
|
|
@@ -14,6 +14,17 @@ export const serverFeatures = (config: ResolvedConfig): string[] => {
|
|
|
14
14
|
if (config.ai.mcp.enabled) {
|
|
15
15
|
features.push("MCP server");
|
|
16
16
|
}
|
|
17
|
+
// The built-in playground proxy (`openapi.playground.proxy: true`) is a
|
|
18
|
+
// live fetch endpoint at `/_api-proxy`; an external proxy URL (string) or a
|
|
19
|
+
// proxy-less playground stays fully static.
|
|
20
|
+
if (
|
|
21
|
+
config.openapi.enabled &&
|
|
22
|
+
config.openapi.renderer === "blume" &&
|
|
23
|
+
config.openapi.playground.enabled &&
|
|
24
|
+
config.openapi.playground.proxy === true
|
|
25
|
+
) {
|
|
26
|
+
features.push("API playground proxy");
|
|
27
|
+
}
|
|
17
28
|
// Mixedbread (and any future provider) that proxies queries through a secret
|
|
18
29
|
// server endpoint can't run on a static build.
|
|
19
30
|
if (searchProviderMeta(config.search.provider).requiresServer) {
|
|
@@ -413,11 +413,18 @@ const scanLinkLine = (
|
|
|
413
413
|
// for its text — a label that contains the same text (e.g. `[/a/b](/a/b)`)
|
|
414
414
|
// would otherwise report the column inside the label.
|
|
415
415
|
const targetOffset = targetOffsetIn(match[0], target, match.groups?.title);
|
|
416
|
-
|
|
416
|
+
const entry: PageLink = {
|
|
417
417
|
column: match.index + targetOffset + 1,
|
|
418
418
|
line: lineNumber,
|
|
419
419
|
target,
|
|
420
|
-
}
|
|
420
|
+
};
|
|
421
|
+
// `MD_LINK` matches the `[label](target)` tail of an image embed too; the
|
|
422
|
+
// preceding `!` is what marks the target as going through the image
|
|
423
|
+
// pipeline rather than resolving as a site route.
|
|
424
|
+
if (masked[match.index - 1] === "!") {
|
|
425
|
+
entry.image = true;
|
|
426
|
+
}
|
|
427
|
+
links.push(entry);
|
|
421
428
|
// An image nested in the label (`[](/target)`) carries its
|
|
422
429
|
// own target; surface it too so a missing image is still caught.
|
|
423
430
|
const label = match[0].slice(0, targetOffset - "](".length);
|
|
@@ -432,6 +439,7 @@ const scanLinkLine = (
|
|
|
432
439
|
image.index +
|
|
433
440
|
targetOffsetIn(image[0], imageTarget, image.groups?.title) +
|
|
434
441
|
1,
|
|
442
|
+
image: true,
|
|
435
443
|
line: lineNumber,
|
|
436
444
|
target: imageTarget,
|
|
437
445
|
});
|
package/src/core/types.ts
CHANGED
|
@@ -58,6 +58,10 @@ export interface Heading {
|
|
|
58
58
|
export interface PageLink {
|
|
59
59
|
/** Raw link target as written, e.g. `./foo`, `/api#auth`, `https://x.dev`. */
|
|
60
60
|
target: string;
|
|
61
|
+
/** Set when the target was written as an image embed (``) —
|
|
62
|
+
* only those go through the image pipeline; a plain link to the same path
|
|
63
|
+
* resolves as a site route. */
|
|
64
|
+
image?: boolean;
|
|
61
65
|
/** 1-based line number in the source file. */
|
|
62
66
|
line: number;
|
|
63
67
|
/** 1-based column of the target within the line. */
|
|
@@ -269,9 +273,12 @@ export interface Navigation {
|
|
|
269
273
|
sidebar: NavNode[];
|
|
270
274
|
/**
|
|
271
275
|
* The tree root in final path space — localized and based (`/`, `/en`,
|
|
272
|
-
* `/docs`)
|
|
273
|
-
*
|
|
274
|
-
*
|
|
276
|
+
* `/docs`), and versionized for an archived version tree (`/v1.0`). Tab
|
|
277
|
+
* paths share that space except under a version, where they stay in
|
|
278
|
+
* current-docs space — so the root tab is the tab this root sits under
|
|
279
|
+
* (`isRootTab`), not necessarily the tab at this exact path, and must be
|
|
280
|
+
* scoped as the root tab, not as a section tab. Absent on older serialized
|
|
281
|
+
* graphs; treat as `/`.
|
|
275
282
|
*/
|
|
276
283
|
root?: string;
|
|
277
284
|
/** Pinned links shown above the sidebar sections, unscoped by tab. */
|
package/src/openapi/model.ts
CHANGED
|
@@ -98,6 +98,13 @@ export interface ApiSpecData {
|
|
|
98
98
|
codeSamples: string[];
|
|
99
99
|
/** Whether nested schema rows start expanded. */
|
|
100
100
|
expandSchemas: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* The "Try it" playground: whether operation pages render it, and the
|
|
103
|
+
* resolved proxy the Send button targets — `false` for direct requests, a
|
|
104
|
+
* URL string otherwise (the built-in `/_api-proxy` route already carries
|
|
105
|
+
* the site `basePath`).
|
|
106
|
+
*/
|
|
107
|
+
playground: { enabled: boolean; proxy: string | false };
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
/** The generated `blume:openapi` module: specs keyed by {@link ApiSpecData.slug}. */
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The playground's CORS proxy. Browsers block the "Try it" panel's `fetch`
|
|
3
|
+
* whenever the documented API doesn't allow cross-origin requests from the
|
|
4
|
+
* docs site, so `openapi.playground.proxy: true` mounts this handler at
|
|
5
|
+
* `/_api-proxy` (server output only) and the client sends its real request
|
|
6
|
+
* here as `?url=<encoded target>` instead. The handler replays the request
|
|
7
|
+
* upstream and mirrors the response back, so the browser only ever talks
|
|
8
|
+
* same-origin.
|
|
9
|
+
*
|
|
10
|
+
* Kept dependency-free and `fetch`-injectable so it unit-tests without a
|
|
11
|
+
* network and stays safe to bundle into the generated endpoint file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Request headers never forwarded upstream: hop-by-hop headers describe this
|
|
16
|
+
* connection (not the upstream one), `host`/`origin`/`referer` would leak or
|
|
17
|
+
* misattribute the docs site, and `cookie` would forward reader credentials
|
|
18
|
+
* to an arbitrary target. `accept-encoding`/`content-length` are recomputed
|
|
19
|
+
* by the runtime's own fetch.
|
|
20
|
+
*/
|
|
21
|
+
const REQUEST_DROP = {
|
|
22
|
+
"accept-encoding": true,
|
|
23
|
+
connection: true,
|
|
24
|
+
"content-length": true,
|
|
25
|
+
cookie: true,
|
|
26
|
+
host: true,
|
|
27
|
+
origin: true,
|
|
28
|
+
referer: true,
|
|
29
|
+
} satisfies Record<string, true>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Upstream response headers never returned to the browser: the runtime's
|
|
33
|
+
* fetch already decoded the body (so `content-encoding`/`content-length` no
|
|
34
|
+
* longer describe it), hop-by-hop headers belong to the upstream connection
|
|
35
|
+
* rather than ours, and `set-cookie` would let the documented API plant
|
|
36
|
+
* cookies on the docs origin — where they'd ride along on every later docs
|
|
37
|
+
* request, including the proxy's own.
|
|
38
|
+
*/
|
|
39
|
+
const RESPONSE_DROP = {
|
|
40
|
+
connection: true,
|
|
41
|
+
"content-encoding": true,
|
|
42
|
+
"content-length": true,
|
|
43
|
+
"keep-alive": true,
|
|
44
|
+
"set-cookie": true,
|
|
45
|
+
"transfer-encoding": true,
|
|
46
|
+
} satisfies Record<string, true>;
|
|
47
|
+
|
|
48
|
+
/** Redirect statuses the handler resolves itself; see {@link followUpstream}. */
|
|
49
|
+
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
|
50
|
+
|
|
51
|
+
/** Hops followed before giving up, matching fetch's own redirect limit. */
|
|
52
|
+
const MAX_REDIRECTS = 20;
|
|
53
|
+
|
|
54
|
+
/** A 400 the playground client can render verbatim. */
|
|
55
|
+
const badRequest = (error: string): Response =>
|
|
56
|
+
Response.json({ error }, { status: 400 });
|
|
57
|
+
|
|
58
|
+
/** Copy headers, skipping the given denylist (names are already lowercase). */
|
|
59
|
+
const filterHeaders = (
|
|
60
|
+
source: Headers,
|
|
61
|
+
drop: Record<string, true>
|
|
62
|
+
): Headers => {
|
|
63
|
+
const headers = new Headers();
|
|
64
|
+
for (const [name, value] of source) {
|
|
65
|
+
if (!drop[name]) {
|
|
66
|
+
headers.set(name, value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return headers;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** A 403 for a target no configured spec declares as one of its servers. */
|
|
73
|
+
const forbidden = (origin: string): Response =>
|
|
74
|
+
Response.json(
|
|
75
|
+
{
|
|
76
|
+
error:
|
|
77
|
+
`${origin} is not one of the API servers this documentation declares, ` +
|
|
78
|
+
"so the docs proxy will not request it.",
|
|
79
|
+
},
|
|
80
|
+
{ status: 403 }
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Follow the upstream chain by hand, re-checking every hop. Redirects are
|
|
85
|
+
* deliberately NOT delegated to fetch: `redirect: "follow"` would chase a
|
|
86
|
+
* `Location` to any host, so an allowlisted first hop could bounce the docs
|
|
87
|
+
* server onto an internal address it must never reach. The rewrite rules match
|
|
88
|
+
* fetch's own — 303 (and 301/302, as every browser does) degrade to a bodyless
|
|
89
|
+
* GET, 307/308 replay the method and body.
|
|
90
|
+
*/
|
|
91
|
+
const followUpstream = async (args: {
|
|
92
|
+
allowed: ReadonlySet<string>;
|
|
93
|
+
body: ArrayBuffer | undefined;
|
|
94
|
+
fetchImpl: typeof fetch;
|
|
95
|
+
headers: Headers;
|
|
96
|
+
/** Hops already followed; the chain is bounded by {@link MAX_REDIRECTS}. */
|
|
97
|
+
hop: number;
|
|
98
|
+
method: string;
|
|
99
|
+
url: URL;
|
|
100
|
+
}): Promise<Response> => {
|
|
101
|
+
const response = await args.fetchImpl(args.url, {
|
|
102
|
+
body: args.body,
|
|
103
|
+
headers: args.headers,
|
|
104
|
+
method: args.method,
|
|
105
|
+
redirect: "manual",
|
|
106
|
+
});
|
|
107
|
+
const location = REDIRECT_STATUS.has(response.status)
|
|
108
|
+
? response.headers.get("location")
|
|
109
|
+
: null;
|
|
110
|
+
if (location === null) {
|
|
111
|
+
return response;
|
|
112
|
+
}
|
|
113
|
+
if (args.hop >= MAX_REDIRECTS) {
|
|
114
|
+
throw new Error(`Too many redirects from ${args.url.href}.`);
|
|
115
|
+
}
|
|
116
|
+
let next: URL;
|
|
117
|
+
try {
|
|
118
|
+
next = new URL(location, args.url);
|
|
119
|
+
} catch {
|
|
120
|
+
throw new Error(`Upstream redirected to an invalid URL: ${location}`);
|
|
121
|
+
}
|
|
122
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
123
|
+
throw new Error(`Upstream redirected to a non-http(s) URL: ${location}`);
|
|
124
|
+
}
|
|
125
|
+
if (!args.allowed.has(next.origin)) {
|
|
126
|
+
return forbidden(next.origin);
|
|
127
|
+
}
|
|
128
|
+
// 307/308 replay the request as-is; the rest degrade to a bodyless GET.
|
|
129
|
+
const replay = response.status === 307 || response.status === 308;
|
|
130
|
+
return followUpstream({
|
|
131
|
+
...args,
|
|
132
|
+
body: replay ? args.body : undefined,
|
|
133
|
+
hop: args.hop + 1,
|
|
134
|
+
method: replay || args.method === "HEAD" ? args.method : "GET",
|
|
135
|
+
url: next,
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build the `/_api-proxy` fetch handler. The client sends its REAL method,
|
|
141
|
+
* headers, and body to `?url=<encodeURIComponent(target)>`; the handler
|
|
142
|
+
* forwards them (minus {@link REQUEST_DROP}) and mirrors the upstream response
|
|
143
|
+
* (minus {@link RESPONSE_DROP}) with an `x-blume-proxy` marker. An unreachable
|
|
144
|
+
* upstream is a 502 with a JSON `error`.
|
|
145
|
+
*
|
|
146
|
+
* `origins` is the allowlist: the origins of the `servers` the documented specs
|
|
147
|
+
* themselves declare (derived at build time). Without it the endpoint would be
|
|
148
|
+
* an open proxy — any visitor could aim the docs deployment at cloud metadata
|
|
149
|
+
* or a service on its private network. Non-absolute and non-http(s) targets are
|
|
150
|
+
* a 400; anything outside the allowlist, including on a redirect hop, is a 403.
|
|
151
|
+
* Loopback and private addresses need no separate rule: they are reachable only
|
|
152
|
+
* when a spec documents them, which is exactly the local-API case that must
|
|
153
|
+
* keep working.
|
|
154
|
+
*/
|
|
155
|
+
export const createPlaygroundProxyHandler = (
|
|
156
|
+
origins: readonly string[],
|
|
157
|
+
fetchImpl: typeof fetch = fetch
|
|
158
|
+
) => {
|
|
159
|
+
const allowed = new Set(origins);
|
|
160
|
+
return async (request: Request): Promise<Response> => {
|
|
161
|
+
const target = new URL(request.url).searchParams.get("url");
|
|
162
|
+
if (!target) {
|
|
163
|
+
return badRequest("Missing `url` query parameter.");
|
|
164
|
+
}
|
|
165
|
+
let parsed: URL;
|
|
166
|
+
try {
|
|
167
|
+
parsed = new URL(target);
|
|
168
|
+
} catch {
|
|
169
|
+
return badRequest(`Invalid target URL: ${target}`);
|
|
170
|
+
}
|
|
171
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
172
|
+
return badRequest("Only http(s) target URLs are allowed.");
|
|
173
|
+
}
|
|
174
|
+
if (!allowed.has(parsed.origin)) {
|
|
175
|
+
return forbidden(parsed.origin);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Buffer the body instead of streaming it: GET/HEAD must not carry one
|
|
179
|
+
// (fetch rejects it), and a buffered body avoids the `duplex` requirement
|
|
180
|
+
// streaming request bodies have in Node.
|
|
181
|
+
const body =
|
|
182
|
+
request.method === "GET" || request.method === "HEAD"
|
|
183
|
+
? undefined
|
|
184
|
+
: await request.arrayBuffer();
|
|
185
|
+
|
|
186
|
+
let upstream: Response;
|
|
187
|
+
try {
|
|
188
|
+
upstream = await followUpstream({
|
|
189
|
+
allowed,
|
|
190
|
+
body,
|
|
191
|
+
fetchImpl,
|
|
192
|
+
headers: filterHeaders(request.headers, REQUEST_DROP),
|
|
193
|
+
hop: 0,
|
|
194
|
+
method: request.method,
|
|
195
|
+
url: parsed,
|
|
196
|
+
});
|
|
197
|
+
} catch (error) {
|
|
198
|
+
// SAFETY: followUpstream itself only throws `new Error(...)`, and a
|
|
199
|
+
// failed fetch rejects with a TypeError per spec — both are Errors
|
|
200
|
+
// carrying `.message`.
|
|
201
|
+
return Response.json(
|
|
202
|
+
{ error: (error as Error).message },
|
|
203
|
+
{ status: 502 }
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const headers = filterHeaders(upstream.headers, RESPONSE_DROP);
|
|
208
|
+
// Marks proxied responses so the client (and debugging humans) can tell
|
|
209
|
+
// them apart from direct responses.
|
|
210
|
+
headers.set("x-blume-proxy", "1");
|
|
211
|
+
return new Response(upstream.body, {
|
|
212
|
+
headers,
|
|
213
|
+
status: upstream.status,
|
|
214
|
+
statusText: upstream.statusText,
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
};
|
|
@@ -25,6 +25,12 @@ export interface ReferenceDisplay {
|
|
|
25
25
|
codeSamples: string[];
|
|
26
26
|
/** Whether nested schema rows start expanded. */
|
|
27
27
|
expandSchemas: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The "Try it" playground: whether operation pages render it, and the CORS
|
|
30
|
+
* proxy the Send button routes through (`false` off, a URL string, or
|
|
31
|
+
* `true` for the built-in `/_api-proxy` endpoint).
|
|
32
|
+
*/
|
|
33
|
+
playground: { enabled: boolean; proxy: string | boolean };
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
/** A spec source resolved to a concrete route, label, and renderer. */
|
|
@@ -190,6 +196,7 @@ export const resolveReferences = (
|
|
|
190
196
|
{
|
|
191
197
|
codeSamples: config.openapi.codeSamples,
|
|
192
198
|
expandSchemas: config.openapi.expandSchemas,
|
|
199
|
+
playground: config.openapi.playground,
|
|
193
200
|
},
|
|
194
201
|
config.basePath
|
|
195
202
|
),
|
|
@@ -201,6 +208,7 @@ export const resolveReferences = (
|
|
|
201
208
|
{
|
|
202
209
|
codeSamples: config.asyncapi.codeSamples,
|
|
203
210
|
expandSchemas: config.asyncapi.expandSchemas,
|
|
211
|
+
playground: config.asyncapi.playground,
|
|
204
212
|
},
|
|
205
213
|
config.basePath
|
|
206
214
|
),
|
package/src/openapi/source.ts
CHANGED
|
@@ -182,6 +182,18 @@ export const openApiSource = (
|
|
|
182
182
|
const { document, warnings, operations, tags, extractWarnings } =
|
|
183
183
|
await parseReference(reference, ctx);
|
|
184
184
|
const info = document.info ?? { title: reference.label, version: "" };
|
|
185
|
+
// The playground proxy resolves here, not client-side: `true` selects
|
|
186
|
+
// the built-in `/_api-proxy` route (mounted under the site `basePath`,
|
|
187
|
+
// like every served URL this module emits), a non-empty string is an
|
|
188
|
+
// external proxy used verbatim, and anything else (`false`, `""`)
|
|
189
|
+
// means the Send button fetches the API directly.
|
|
190
|
+
const { proxy: configuredProxy } = reference.display.playground;
|
|
191
|
+
let proxy: string | false = false;
|
|
192
|
+
if (configuredProxy === true) {
|
|
193
|
+
proxy = withBasePath(reference.basePath, "/_api-proxy");
|
|
194
|
+
} else if (configuredProxy !== false && configuredProxy !== "") {
|
|
195
|
+
proxy = configuredProxy;
|
|
196
|
+
}
|
|
185
197
|
const spec: ApiSpecData = {
|
|
186
198
|
codeSamples: reference.display.codeSamples,
|
|
187
199
|
description: info.description ?? "",
|
|
@@ -202,6 +214,7 @@ export const openApiSource = (
|
|
|
202
214
|
},
|
|
203
215
|
])
|
|
204
216
|
),
|
|
217
|
+
playground: { enabled: reference.display.playground.enabled, proxy },
|
|
205
218
|
route: reference.route,
|
|
206
219
|
slug: reference.slug,
|
|
207
220
|
tags,
|
package/src/registry/eject.ts
CHANGED
|
@@ -127,11 +127,10 @@ const askFiles = async (
|
|
|
127
127
|
const grounded = ask.provider !== "inkeep";
|
|
128
128
|
const files = [
|
|
129
129
|
{
|
|
130
|
-
content: askEndpointTemplate(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
),
|
|
130
|
+
content: askEndpointTemplate(resolveAskBackend(ask), grounded, {
|
|
131
|
+
instructions: ask.instructions,
|
|
132
|
+
retrieval: ask.retrieval,
|
|
133
|
+
}),
|
|
135
134
|
path: join(srcDir, "pages", "api", "ask.ts"),
|
|
136
135
|
},
|
|
137
136
|
];
|