blume 2.2.0 → 2.2.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 +15 -0
- package/dist/cli/{chunk-xgq4eytj.js → chunk-080mfch6.js} +15 -15
- package/dist/cli/{chunk-br0n8n3e.js → chunk-1ch3ct3r.js} +14 -14
- package/dist/cli/{chunk-49r370mb.js → chunk-2ssh60qh.js} +6 -6
- package/dist/cli/{chunk-1cm0h819.js → chunk-34wap3ed.js} +3 -3
- package/dist/cli/{chunk-fqy4n3q2.js → chunk-40vk8kgf.js} +7 -7
- package/dist/cli/{chunk-96snx8yy.js → chunk-487b95qy.js} +3 -3
- package/dist/cli/{chunk-kkrteb15.js → chunk-4zxh25hc.js} +2 -2
- package/dist/cli/{chunk-cn3dy3hz.js → chunk-69rjf786.js} +5 -5
- package/dist/cli/{chunk-dmx6cezx.js → chunk-9h8mh2tk.js} +3 -3
- package/dist/cli/{chunk-dmx6cezx.js.map → chunk-9h8mh2tk.js.map} +2 -2
- package/dist/cli/{chunk-t5xx74rd.js → chunk-9jqmq5hv.js} +6 -6
- package/dist/cli/{chunk-8b489v1b.js → chunk-aafhwx74.js} +1 -1
- package/dist/cli/{chunk-2nr29y95.js → chunk-b680sv30.js} +3 -3
- package/dist/cli/{chunk-rkqae7kh.js → chunk-beb0z983.js} +16 -16
- package/dist/cli/{chunk-th0pgaj5.js → chunk-c5bjxq8v.js} +2 -2
- package/dist/cli/{chunk-wehv26ry.js → chunk-e1n41w9m.js} +3 -3
- package/dist/cli/{chunk-frk2wdq1.js → chunk-etckvbrh.js} +14 -14
- package/dist/cli/{chunk-ahc5gc9h.js → chunk-f260jdrn.js} +5 -5
- package/dist/cli/{chunk-05f21r8h.js → chunk-fnxgtjeq.js} +1 -1
- package/dist/cli/{chunk-4rftmttv.js → chunk-gwy55cx3.js} +2 -2
- package/dist/cli/{chunk-4rftmttv.js.map → chunk-gwy55cx3.js.map} +2 -2
- package/dist/cli/{chunk-vnbvhgmd.js → chunk-gxhcf2hs.js} +2 -2
- package/dist/cli/{chunk-bjk964fp.js → chunk-h0qwdbem.js} +2 -2
- package/dist/cli/{chunk-1rkxzg3n.js → chunk-hktq4j5r.js} +15 -12
- package/dist/cli/{chunk-1rkxzg3n.js.map → chunk-hktq4j5r.js.map} +3 -3
- package/dist/cli/{chunk-enk76qd5.js → chunk-hn3g2b89.js} +9 -9
- package/dist/cli/{chunk-w6e455pz.js → chunk-j4nfgn2j.js} +1 -1
- package/dist/cli/{chunk-as09pafp.js → chunk-k45sq16q.js} +4 -4
- package/dist/cli/{chunk-as09pafp.js.map → chunk-k45sq16q.js.map} +3 -3
- package/dist/cli/{chunk-p8zg3a2h.js → chunk-kmswjxj9.js} +3 -3
- package/dist/cli/{chunk-xt0qvz1t.js → chunk-nqna8dzp.js} +17 -17
- package/dist/cli/{chunk-dyk3r7jw.js → chunk-p834rkcn.js} +5 -5
- package/dist/cli/{chunk-dpcahcrb.js → chunk-ppa6mg4d.js} +6 -6
- package/dist/cli/{chunk-79bzzkx4.js → chunk-q1wwrxzw.js} +2 -2
- package/dist/cli/{chunk-m9bp2hc1.js → chunk-qcyxkj44.js} +1 -1
- package/dist/cli/{chunk-dm4mg9bj.js → chunk-r5nm9w0c.js} +740 -685
- package/dist/cli/{chunk-dm4mg9bj.js.map → chunk-r5nm9w0c.js.map} +8 -6
- package/dist/cli/{chunk-a3ydrqny.js → chunk-rtvgmg2j.js} +2 -2
- package/dist/cli/{chunk-c69tw29j.js → chunk-t2h8pwy1.js} +4 -4
- package/dist/cli/{chunk-vzy9rh6y.js → chunk-x60e15st.js} +17 -17
- package/dist/cli/{chunk-mrvh4c88.js → chunk-x7bh6d9j.js} +4 -4
- package/dist/cli/{chunk-7tekknev.js → chunk-ydqzkhjn.js} +6 -6
- package/dist/cli/{chunk-gprwej7h.js → chunk-yg8ckc3g.js} +2 -2
- package/dist/cli/{chunk-8f8xw4x2.js → chunk-zxd5jrgx.js} +14 -4
- package/dist/cli/{chunk-8f8xw4x2.js.map → chunk-zxd5jrgx.js.map} +3 -3
- package/dist/cli/index.js +18 -18
- package/dist/types/core/config-input.d.ts +37 -5
- package/dist/types/core/data.d.ts +6 -2
- package/dist/types/core/mcp-clients.d.ts +47 -0
- package/dist/types/core/nav-diagnostics.d.ts +7 -0
- package/dist/types/core/schema.d.ts +55 -4
- package/dist/types/ratelimit/index.d.ts +2 -0
- package/dist/types/ratelimit/memory.d.ts +1 -1
- package/dist/types/ratelimit/schema.d.ts +40 -0
- package/dist/types/ratelimit/unkey.d.ts +48 -0
- package/docs/02-deployment.mdx +1 -0
- package/docs/configuration/index.mdx +5 -4
- package/docs/configuration/rate-limiting.mdx +20 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/discoverability/mcp.mdx +40 -8
- package/package.json +1 -1
- package/skills/blume-migrate/SKILL.md +2 -2
- package/skills/blume-migrate/references/docusaurus.md +2 -2
- package/skills/blume-migrate/references/gitbook.md +2 -2
- package/skills/blume-migrate/references/github-wiki.md +2 -2
- package/skills/blume-migrate/references/jekyll.md +2 -2
- package/skills/blume-migrate/references/mkdocs.md +2 -2
- package/skills/blume-migrate/references/nextra.md +3 -3
- package/skills/blume-migrate/references/readme.md +1 -1
- package/skills/blume-migrate/references/redocly.md +2 -2
- package/skills/blume-migrate/references/vitepress.md +2 -2
- package/skills/blume-migrate/references/vuepress.md +1 -1
- package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +1 -1
- package/src/astro/generate.ts +15 -5
- package/src/astro/templates.ts +4 -4
- package/src/cli/required-secrets.ts +0 -1
- package/src/components/layout/PageActions.astro +119 -34
- package/src/components/layout/RootLayout.astro +18 -2
- package/src/components/layout/SiteFooter.astro +31 -8
- package/src/core/config-input.ts +38 -4
- package/src/core/data.ts +10 -3
- package/src/core/mcp-clients.ts +87 -0
- package/src/core/nav-diagnostics.ts +25 -0
- package/src/core/schema.ts +93 -3
- package/src/openapi/proxy.ts +5 -1
- package/src/ratelimit/index.ts +2 -0
- package/src/ratelimit/memory.ts +1 -1
- package/src/ratelimit/runtime.ts +81 -0
- package/src/ratelimit/schema.ts +2 -0
- package/src/ratelimit/unkey.ts +61 -0
- package/src/registry/registry.ts +1 -1
- /package/dist/cli/{chunk-xgq4eytj.js.map → chunk-080mfch6.js.map} +0 -0
- /package/dist/cli/{chunk-br0n8n3e.js.map → chunk-1ch3ct3r.js.map} +0 -0
- /package/dist/cli/{chunk-49r370mb.js.map → chunk-2ssh60qh.js.map} +0 -0
- /package/dist/cli/{chunk-1cm0h819.js.map → chunk-34wap3ed.js.map} +0 -0
- /package/dist/cli/{chunk-fqy4n3q2.js.map → chunk-40vk8kgf.js.map} +0 -0
- /package/dist/cli/{chunk-96snx8yy.js.map → chunk-487b95qy.js.map} +0 -0
- /package/dist/cli/{chunk-kkrteb15.js.map → chunk-4zxh25hc.js.map} +0 -0
- /package/dist/cli/{chunk-cn3dy3hz.js.map → chunk-69rjf786.js.map} +0 -0
- /package/dist/cli/{chunk-t5xx74rd.js.map → chunk-9jqmq5hv.js.map} +0 -0
- /package/dist/cli/{chunk-8b489v1b.js.map → chunk-aafhwx74.js.map} +0 -0
- /package/dist/cli/{chunk-2nr29y95.js.map → chunk-b680sv30.js.map} +0 -0
- /package/dist/cli/{chunk-rkqae7kh.js.map → chunk-beb0z983.js.map} +0 -0
- /package/dist/cli/{chunk-th0pgaj5.js.map → chunk-c5bjxq8v.js.map} +0 -0
- /package/dist/cli/{chunk-wehv26ry.js.map → chunk-e1n41w9m.js.map} +0 -0
- /package/dist/cli/{chunk-frk2wdq1.js.map → chunk-etckvbrh.js.map} +0 -0
- /package/dist/cli/{chunk-ahc5gc9h.js.map → chunk-f260jdrn.js.map} +0 -0
- /package/dist/cli/{chunk-05f21r8h.js.map → chunk-fnxgtjeq.js.map} +0 -0
- /package/dist/cli/{chunk-vnbvhgmd.js.map → chunk-gxhcf2hs.js.map} +0 -0
- /package/dist/cli/{chunk-bjk964fp.js.map → chunk-h0qwdbem.js.map} +0 -0
- /package/dist/cli/{chunk-enk76qd5.js.map → chunk-hn3g2b89.js.map} +0 -0
- /package/dist/cli/{chunk-w6e455pz.js.map → chunk-j4nfgn2j.js.map} +0 -0
- /package/dist/cli/{chunk-p8zg3a2h.js.map → chunk-kmswjxj9.js.map} +0 -0
- /package/dist/cli/{chunk-xt0qvz1t.js.map → chunk-nqna8dzp.js.map} +0 -0
- /package/dist/cli/{chunk-dyk3r7jw.js.map → chunk-p834rkcn.js.map} +0 -0
- /package/dist/cli/{chunk-dpcahcrb.js.map → chunk-ppa6mg4d.js.map} +0 -0
- /package/dist/cli/{chunk-79bzzkx4.js.map → chunk-q1wwrxzw.js.map} +0 -0
- /package/dist/cli/{chunk-m9bp2hc1.js.map → chunk-qcyxkj44.js.map} +0 -0
- /package/dist/cli/{chunk-a3ydrqny.js.map → chunk-rtvgmg2j.js.map} +0 -0
- /package/dist/cli/{chunk-c69tw29j.js.map → chunk-t2h8pwy1.js.map} +0 -0
- /package/dist/cli/{chunk-vzy9rh6y.js.map → chunk-x60e15st.js.map} +0 -0
- /package/dist/cli/{chunk-mrvh4c88.js.map → chunk-x7bh6d9j.js.map} +0 -0
- /package/dist/cli/{chunk-7tekknev.js.map → chunk-ydqzkhjn.js.map} +0 -0
- /package/dist/cli/{chunk-gprwej7h.js.map → chunk-yg8ckc3g.js.map} +0 -0
package/src/core/schema.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { isExternalUrl, normalizeBasePath } from "./base-path.ts";
|
|
|
43
43
|
import { FOOTER_SOCIALS } from "./footer.ts";
|
|
44
44
|
import { PUBLIC_HOST_URL } from "./github.ts";
|
|
45
45
|
import { uiLocaleOverridesSchema } from "./i18n-ui.ts";
|
|
46
|
+
import { isBuiltInMcpClient, mcpClients } from "./mcp-clients.ts";
|
|
46
47
|
import { openInChatProviders } from "./open-in-chat.ts";
|
|
47
48
|
import { PAGE_MODES } from "./page-modes.ts";
|
|
48
49
|
import { redirectPatternError } from "./redirect-patterns.ts";
|
|
@@ -487,10 +488,13 @@ const apiConfigSchema = z.strictObject({
|
|
|
487
488
|
});
|
|
488
489
|
|
|
489
490
|
/**
|
|
490
|
-
* The site footer: a row of links, and social profile
|
|
491
|
-
* has no footer (a `components.ts` `Footer` still
|
|
491
|
+
* The site footer: a copyright line, a row of links, and social profile
|
|
492
|
+
* icons. Unset, the site has no footer (a `components.ts` `Footer` still
|
|
493
|
+
* renders).
|
|
492
494
|
*/
|
|
493
495
|
const footerConfigSchema = z.strictObject({
|
|
496
|
+
/** A line of plain text between the links and the icons (`© 2026 Acme, Inc.`). */
|
|
497
|
+
copyright: localizableLabelSchema.optional(),
|
|
494
498
|
/** Links in one row, in the order written. */
|
|
495
499
|
links: z
|
|
496
500
|
.array(z.strictObject({ href: z.string(), label: localizableLabelSchema }))
|
|
@@ -891,7 +895,91 @@ const publicJwkSchema = z
|
|
|
891
895
|
}
|
|
892
896
|
});
|
|
893
897
|
|
|
898
|
+
// The label is the row button's only accessible name (its icon is
|
|
899
|
+
// decorative), so unlike a header link's it can't be blank in any locale.
|
|
900
|
+
const mcpClientLabelSchema = z.union(
|
|
901
|
+
[
|
|
902
|
+
z.string().trim().min(1),
|
|
903
|
+
z
|
|
904
|
+
.record(z.string(), z.string().trim().min(1))
|
|
905
|
+
.refine((value) => Object.keys(value).length > 0, {
|
|
906
|
+
message: "Provide at least one locale's label.",
|
|
907
|
+
}),
|
|
908
|
+
],
|
|
909
|
+
{ error: "Expected a label, or a map of locale code to label" }
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
// A "Connect to MCP" row for a client Blume doesn't ship: its `command` is
|
|
913
|
+
// copied with `{name}` and `{url}` filled in.
|
|
914
|
+
const mcpCustomClientSchema = z.strictObject({
|
|
915
|
+
command: z.string().trim().min(1),
|
|
916
|
+
icon: iconName.optional(),
|
|
917
|
+
label: mcpClientLabelSchema,
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
const ACCEPTED_MCP_CLIENTS = `${mcpClients.map((key) => `"${key}"`).join(", ")}, or a custom client { label, command, icon? }`;
|
|
921
|
+
|
|
922
|
+
/** A union's issue: every member failed, each with issues of its own. */
|
|
923
|
+
const isUnionIssue = (
|
|
924
|
+
issue: z.core.$ZodIssue
|
|
925
|
+
): issue is z.core.$ZodIssueInvalidUnion => issue.code === "invalid_union";
|
|
926
|
+
|
|
927
|
+
// A bad entry fails the list member of `clients`, which zod reports as a bare
|
|
928
|
+
// "Invalid input" on the whole list; that union issue is all this map sees.
|
|
929
|
+
// The entries' issues ride along in `errors`. The culprit is an entry that
|
|
930
|
+
// matches neither a built-in key nor a custom client, which fails as a union
|
|
931
|
+
// at its index (a blank field or an unknown key elsewhere doesn't fail the
|
|
932
|
+
// list on its own). Name that entry, and for a custom client its bad field.
|
|
933
|
+
const mcpClientsError = (issue: z.core.$ZodRawIssue): string => {
|
|
934
|
+
const entryIssue = (issue.code === "invalid_union" ? issue.errors : [])
|
|
935
|
+
.flat()
|
|
936
|
+
.filter(isUnionIssue)
|
|
937
|
+
.find((nested) => Number.isInteger(nested.path[0]));
|
|
938
|
+
if (!entryIssue) {
|
|
939
|
+
return `agents.mcp.clients takes true, false, or a list whose entries are each ${ACCEPTED_MCP_CLIENTS}.`;
|
|
940
|
+
}
|
|
941
|
+
const index = Number(entryIssue.path[0]);
|
|
942
|
+
const at = `agents.mcp.clients.${String(index)}`;
|
|
943
|
+
const entry = Array.isArray(issue.input) ? issue.input[index] : null;
|
|
944
|
+
if (isString(entry)) {
|
|
945
|
+
return `${at} is "${entry}", which isn't a built-in client. Use ${ACCEPTED_MCP_CLIENTS}.`;
|
|
946
|
+
}
|
|
947
|
+
const fieldIssue = entryIssue.errors
|
|
948
|
+
.flat()
|
|
949
|
+
.find((nested) => nested.path.length > 0);
|
|
950
|
+
return fieldIssue
|
|
951
|
+
? `${at}.${fieldIssue.path.map(String).join(".")}: ${fieldIssue.message}. A custom client is { label, command, icon? }.`
|
|
952
|
+
: `${at} isn't ${ACCEPTED_MCP_CLIENTS}.`;
|
|
953
|
+
};
|
|
954
|
+
|
|
894
955
|
const mcpConfigSchema = z.strictObject({
|
|
956
|
+
/**
|
|
957
|
+
* The "Connect to MCP" page action's clients. `true` (the default) lists
|
|
958
|
+
* every built-in client, `false` none (the menu keeps "Copy server URL"),
|
|
959
|
+
* and an array picks built-in clients by key and adds custom ones, in the
|
|
960
|
+
* given order. Normalized to the list so consumers read a plain array.
|
|
961
|
+
*/
|
|
962
|
+
clients: z
|
|
963
|
+
.union(
|
|
964
|
+
[
|
|
965
|
+
z.boolean(),
|
|
966
|
+
z.array(z.union([z.enum(mcpClients), mcpCustomClientSchema])).refine(
|
|
967
|
+
(value) => {
|
|
968
|
+
const keys = value.filter(isBuiltInMcpClient);
|
|
969
|
+
return new Set(keys).size === keys.length;
|
|
970
|
+
},
|
|
971
|
+
{ message: "agents.mcp.clients must not repeat a built-in client." }
|
|
972
|
+
),
|
|
973
|
+
],
|
|
974
|
+
{ error: mcpClientsError }
|
|
975
|
+
)
|
|
976
|
+
.default(true)
|
|
977
|
+
.transform((value) => {
|
|
978
|
+
if (isBoolean(value)) {
|
|
979
|
+
return value ? [...mcpClients] : [];
|
|
980
|
+
}
|
|
981
|
+
return value;
|
|
982
|
+
}),
|
|
895
983
|
enabled: z.boolean().default(false),
|
|
896
984
|
/** Optional system hint passed to connecting agents. */
|
|
897
985
|
instructions: z.string().optional(),
|
|
@@ -1182,6 +1270,8 @@ export type AssistantConfig = NonNullable<
|
|
|
1182
1270
|
>;
|
|
1183
1271
|
export { openInChatProviders } from "./open-in-chat.ts";
|
|
1184
1272
|
export type { OpenInChatProvider } from "./open-in-chat.ts";
|
|
1273
|
+
export { mcpClients } from "./mcp-clients.ts";
|
|
1274
|
+
export type { McpClient, McpCustomClient } from "./mcp-clients.ts";
|
|
1185
1275
|
|
|
1186
1276
|
// Reader-facing "Export" page action (PDF via print, EPUB via client-side
|
|
1187
1277
|
// generation). Off by default. Accepts a shorthand boolean to toggle both
|
|
@@ -2271,7 +2361,7 @@ export const blumeConfigSchema = z
|
|
|
2271
2361
|
ctx.addIssue({
|
|
2272
2362
|
code: z.ZodIssueCode.custom,
|
|
2273
2363
|
message:
|
|
2274
|
-
'rateLimit: cloudflare() counts with a Workers binding, so it needs `deployment: cloudflare()` from "blume/deploy". On another host, use upstash() or memory() from "blume/ratelimit".',
|
|
2364
|
+
'rateLimit: cloudflare() counts with a Workers binding, so it needs `deployment: cloudflare()` from "blume/deploy". On another host, use upstash(), unkey(), or memory() from "blume/ratelimit".',
|
|
2275
2365
|
path: ["rateLimit"],
|
|
2276
2366
|
});
|
|
2277
2367
|
}
|
package/src/openapi/proxy.ts
CHANGED
|
@@ -196,7 +196,11 @@ const followUpstream = async (args: {
|
|
|
196
196
|
signal: AbortSignal;
|
|
197
197
|
url: URL;
|
|
198
198
|
}): Promise<Response> => {
|
|
199
|
-
|
|
199
|
+
// Called bare, never as `args.fetchImpl(...)`: that would run the global
|
|
200
|
+
// `fetch` with `this` set to `args`, which Node allows and workerd rejects
|
|
201
|
+
// as an "Illegal invocation".
|
|
202
|
+
const { fetchImpl } = args;
|
|
203
|
+
const response = await fetchImpl(args.url, {
|
|
200
204
|
body: args.body,
|
|
201
205
|
headers: args.headers,
|
|
202
206
|
method: args.method,
|
package/src/ratelimit/index.ts
CHANGED
|
@@ -28,3 +28,5 @@ export type { MemoryAdapter, RateLimitOptions } from "./memory.ts";
|
|
|
28
28
|
export type { RateLimitAdapter } from "./schema.ts";
|
|
29
29
|
export { upstash } from "./upstash.ts";
|
|
30
30
|
export type { UpstashAdapter, UpstashOptions } from "./upstash.ts";
|
|
31
|
+
export { unkey } from "./unkey.ts";
|
|
32
|
+
export type { UnkeyAdapter, UnkeyOptions } from "./unkey.ts";
|
package/src/ratelimit/memory.ts
CHANGED
|
@@ -35,7 +35,7 @@ export const memoryAdapterSchema = adapterDescriptorSchema(
|
|
|
35
35
|
* platform that runs many short-lived instances (Vercel, Netlify,
|
|
36
36
|
* Cloudflare) each instance keeps its own, so it stops a burst from one
|
|
37
37
|
* reader rather than enforcing the limit exactly. For that, share the count
|
|
38
|
-
* with `upstash()` or `
|
|
38
|
+
* with `upstash()`, `cloudflare()`, or `unkey()`.
|
|
39
39
|
*/
|
|
40
40
|
export const memory = (options: RateLimitOptions = {}): MemoryAdapter => ({
|
|
41
41
|
kind: "memory",
|
package/src/ratelimit/runtime.ts
CHANGED
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
* the shared store fails to count (logged, not blocked). A shared store
|
|
12
12
|
* without its secrets or binding falls back to counting in memory.
|
|
13
13
|
*/
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
14
16
|
import { clientAddressOf } from "../core/client-address.ts";
|
|
15
17
|
import type { ClientContext } from "../core/client-address.ts";
|
|
16
18
|
import { RATE_LIMIT_BINDING } from "./cloudflare.ts";
|
|
17
19
|
import { DEFAULT_REQUESTS, DEFAULT_WINDOW } from "./memory.ts";
|
|
18
20
|
import type { RateLimitAdapter } from "./schema.ts";
|
|
21
|
+
import { UNKEY_MAX_WINDOW, UNKEY_NAMESPACE, unkeySecrets } from "./unkey.ts";
|
|
19
22
|
import { upstashSecrets } from "./upstash.ts";
|
|
20
23
|
|
|
21
24
|
/** What a limiter says about one request. */
|
|
@@ -152,6 +155,68 @@ export const upstashLimiter = (
|
|
|
152
155
|
};
|
|
153
156
|
};
|
|
154
157
|
|
|
158
|
+
/** Unkey's rate limit endpoint. */
|
|
159
|
+
const UNKEY_LIMIT_URL = "https://api.unkey.com/v2/ratelimit.limit";
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* How long Unkey gets to count a request, in milliseconds, before the
|
|
163
|
+
* request is let through without it.
|
|
164
|
+
*/
|
|
165
|
+
export const UNKEY_TIMEOUT = 2000;
|
|
166
|
+
|
|
167
|
+
/** The part of Unkey's reply the limiter reads. */
|
|
168
|
+
const unkeyReplySchema = z.object({
|
|
169
|
+
data: z.object({
|
|
170
|
+
overrideId: z.string().optional(),
|
|
171
|
+
reset: z.number(),
|
|
172
|
+
success: z.boolean(),
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Count with Unkey's rate limit API. Unkey takes identifiers of letters,
|
|
178
|
+
* digits, and `_.:/-`, so anything else in the key (an IPv6 host's brackets,
|
|
179
|
+
* an address's `%` zone) becomes `_`. Its `reset` is a timestamp on Unkey's
|
|
180
|
+
* clock, which this server's may not match, so `retryAfter` is kept between
|
|
181
|
+
* one second and the window: the configured one, or Unkey's longest when an
|
|
182
|
+
* override set in Unkey replaced it.
|
|
183
|
+
*/
|
|
184
|
+
export const unkeyLimiter =
|
|
185
|
+
(
|
|
186
|
+
requests: number,
|
|
187
|
+
window: number,
|
|
188
|
+
namespace: string,
|
|
189
|
+
rootKey: string,
|
|
190
|
+
{ fetch: fetchImpl = fetch, now = Date.now }: LimiterRuntime = {}
|
|
191
|
+
): Limiter =>
|
|
192
|
+
async (key) => {
|
|
193
|
+
const response = await fetchImpl(UNKEY_LIMIT_URL, {
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
duration: window * 1000,
|
|
196
|
+
identifier: key.replaceAll(/[^\w.:/-]/gu, "_"),
|
|
197
|
+
limit: requests,
|
|
198
|
+
namespace,
|
|
199
|
+
}),
|
|
200
|
+
headers: {
|
|
201
|
+
authorization: `Bearer ${rootKey}`,
|
|
202
|
+
"content-type": "application/json",
|
|
203
|
+
},
|
|
204
|
+
method: "POST",
|
|
205
|
+
signal: AbortSignal.timeout(UNKEY_TIMEOUT),
|
|
206
|
+
});
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
await response.body?.cancel();
|
|
209
|
+
throw new Error(`Unkey answered ${response.status}.`);
|
|
210
|
+
}
|
|
211
|
+
const { data } = unkeyReplySchema.parse(await response.json());
|
|
212
|
+
const seconds = Math.ceil((data.reset - now()) / 1000);
|
|
213
|
+
const longest = data.overrideId ? UNKEY_MAX_WINDOW : window;
|
|
214
|
+
return {
|
|
215
|
+
allowed: data.success,
|
|
216
|
+
retryAfter: Math.min(longest, Math.max(1, seconds)),
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
155
220
|
/** Count with a Workers rate limiting binding. */
|
|
156
221
|
export const bindingLimiter =
|
|
157
222
|
(binding: RateLimitBinding, window: number): Limiter =>
|
|
@@ -185,6 +250,22 @@ export const createLimiter = (
|
|
|
185
250
|
return null;
|
|
186
251
|
}
|
|
187
252
|
const { requests, window } = limitOf(adapter);
|
|
253
|
+
if (adapter.kind === "unkey") {
|
|
254
|
+
const [secret] = unkeySecrets(adapter.options);
|
|
255
|
+
const rootKey = runtime.secret?.(secret);
|
|
256
|
+
if (rootKey) {
|
|
257
|
+
return unkeyLimiter(
|
|
258
|
+
requests,
|
|
259
|
+
window,
|
|
260
|
+
adapter.options.namespace ?? UNKEY_NAMESPACE,
|
|
261
|
+
rootKey,
|
|
262
|
+
runtime
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
console.warn(
|
|
266
|
+
`Rate limiting counts in memory: set ${secret} to share the count through Unkey.`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
188
269
|
if (adapter.kind === "cloudflare") {
|
|
189
270
|
if (runtime.binding) {
|
|
190
271
|
return bindingLimiter(runtime.binding, window);
|
package/src/ratelimit/schema.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
|
|
3
3
|
import { cloudflareRateLimitAdapterSchema } from "./cloudflare.ts";
|
|
4
4
|
import { memory, memoryAdapterSchema } from "./memory.ts";
|
|
5
|
+
import { unkeyAdapterSchema } from "./unkey.ts";
|
|
5
6
|
import { upstashAdapterSchema } from "./upstash.ts";
|
|
6
7
|
|
|
7
8
|
const ADAPTER_HINT =
|
|
@@ -12,6 +13,7 @@ export const rateLimitAdapterSchema = z.discriminatedUnion("kind", [
|
|
|
12
13
|
cloudflareRateLimitAdapterSchema,
|
|
13
14
|
memoryAdapterSchema,
|
|
14
15
|
upstashAdapterSchema,
|
|
16
|
+
unkeyAdapterSchema,
|
|
15
17
|
]);
|
|
16
18
|
|
|
17
19
|
export type RateLimitAdapter = z.output<typeof rateLimitAdapterSchema>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import type { AdapterDescriptor } from "../core/adapter.ts";
|
|
4
|
+
import { adapterDescriptorSchema } from "../core/adapter.ts";
|
|
5
|
+
import { rateLimitOptionsSchema } from "./memory.ts";
|
|
6
|
+
import type { RateLimitOptions } from "./memory.ts";
|
|
7
|
+
|
|
8
|
+
/** The env var holding the Unkey root key, unless set. */
|
|
9
|
+
const UNKEY_ROOT_KEY_ENV = "UNKEY_ROOT_KEY";
|
|
10
|
+
|
|
11
|
+
/** The Unkey rate limit namespace the count is kept in, unless set. */
|
|
12
|
+
export const UNKEY_NAMESPACE = "docs";
|
|
13
|
+
|
|
14
|
+
/** The longest window Unkey takes, in seconds (30 days). */
|
|
15
|
+
export const UNKEY_MAX_WINDOW = 2_592_000;
|
|
16
|
+
|
|
17
|
+
/** Options for {@link unkey}. */
|
|
18
|
+
export interface UnkeyOptions extends RateLimitOptions {
|
|
19
|
+
/**
|
|
20
|
+
* The Unkey rate limit namespace, up to 512 characters. Defaults to `docs`;
|
|
21
|
+
* Unkey creates it on the first request.
|
|
22
|
+
*/
|
|
23
|
+
namespace?: string;
|
|
24
|
+
/** Name of the env var holding the root key. Defaults to `UNKEY_ROOT_KEY`. */
|
|
25
|
+
rootKeyEnv?: string;
|
|
26
|
+
/** The window, in seconds: Unkey takes up to 30 days. */
|
|
27
|
+
window?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const unkeyOptionsSchema = rateLimitOptionsSchema.extend({
|
|
31
|
+
namespace: z.string().min(1).max(512).optional(),
|
|
32
|
+
rootKeyEnv: z.string().min(1).optional(),
|
|
33
|
+
window: z.number().int().positive().max(UNKEY_MAX_WINDOW).optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type UnkeyAdapter = AdapterDescriptor<"unkey", UnkeyOptions>;
|
|
37
|
+
|
|
38
|
+
export const unkeyAdapterSchema = adapterDescriptorSchema(
|
|
39
|
+
"unkey",
|
|
40
|
+
unkeyOptionsSchema
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** The env var the root key is read from. */
|
|
44
|
+
export const unkeySecrets = (options: UnkeyOptions): [string] => [
|
|
45
|
+
options.rootKeyEnv ?? UNKEY_ROOT_KEY_ENV,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Counts with Unkey's rate limiting, which every instance on every host
|
|
50
|
+
* shares. Unkey counts in each region and shares the counts between them
|
|
51
|
+
* within moments, so a short burst split across regions can pass more than
|
|
52
|
+
* the limit. It talks to Unkey's REST API, so there is nothing to install;
|
|
53
|
+
* the root key comes from `UNKEY_ROOT_KEY`, or the env var `rootKeyEnv`
|
|
54
|
+
* names.
|
|
55
|
+
*/
|
|
56
|
+
export const unkey = (options: UnkeyOptions = {}): UnkeyAdapter => ({
|
|
57
|
+
kind: "unkey",
|
|
58
|
+
options,
|
|
59
|
+
requiredSecrets: unkeySecrets(options),
|
|
60
|
+
runtimeDeps: [],
|
|
61
|
+
});
|
package/src/registry/registry.ts
CHANGED
|
@@ -264,7 +264,7 @@ export const registry: RegistryItem[] = [
|
|
|
264
264
|
}),
|
|
265
265
|
layoutComponent({
|
|
266
266
|
description:
|
|
267
|
-
"The site footer: a row of links, social icons,
|
|
267
|
+
"The site footer: a copyright line, a row of links, social icons, and the Cookie settings link.",
|
|
268
268
|
file: "SiteFooter.astro",
|
|
269
269
|
name: "footer",
|
|
270
270
|
slot: "Footer",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|