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.
Files changed (125) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cli/{chunk-xgq4eytj.js → chunk-080mfch6.js} +15 -15
  3. package/dist/cli/{chunk-br0n8n3e.js → chunk-1ch3ct3r.js} +14 -14
  4. package/dist/cli/{chunk-49r370mb.js → chunk-2ssh60qh.js} +6 -6
  5. package/dist/cli/{chunk-1cm0h819.js → chunk-34wap3ed.js} +3 -3
  6. package/dist/cli/{chunk-fqy4n3q2.js → chunk-40vk8kgf.js} +7 -7
  7. package/dist/cli/{chunk-96snx8yy.js → chunk-487b95qy.js} +3 -3
  8. package/dist/cli/{chunk-kkrteb15.js → chunk-4zxh25hc.js} +2 -2
  9. package/dist/cli/{chunk-cn3dy3hz.js → chunk-69rjf786.js} +5 -5
  10. package/dist/cli/{chunk-dmx6cezx.js → chunk-9h8mh2tk.js} +3 -3
  11. package/dist/cli/{chunk-dmx6cezx.js.map → chunk-9h8mh2tk.js.map} +2 -2
  12. package/dist/cli/{chunk-t5xx74rd.js → chunk-9jqmq5hv.js} +6 -6
  13. package/dist/cli/{chunk-8b489v1b.js → chunk-aafhwx74.js} +1 -1
  14. package/dist/cli/{chunk-2nr29y95.js → chunk-b680sv30.js} +3 -3
  15. package/dist/cli/{chunk-rkqae7kh.js → chunk-beb0z983.js} +16 -16
  16. package/dist/cli/{chunk-th0pgaj5.js → chunk-c5bjxq8v.js} +2 -2
  17. package/dist/cli/{chunk-wehv26ry.js → chunk-e1n41w9m.js} +3 -3
  18. package/dist/cli/{chunk-frk2wdq1.js → chunk-etckvbrh.js} +14 -14
  19. package/dist/cli/{chunk-ahc5gc9h.js → chunk-f260jdrn.js} +5 -5
  20. package/dist/cli/{chunk-05f21r8h.js → chunk-fnxgtjeq.js} +1 -1
  21. package/dist/cli/{chunk-4rftmttv.js → chunk-gwy55cx3.js} +2 -2
  22. package/dist/cli/{chunk-4rftmttv.js.map → chunk-gwy55cx3.js.map} +2 -2
  23. package/dist/cli/{chunk-vnbvhgmd.js → chunk-gxhcf2hs.js} +2 -2
  24. package/dist/cli/{chunk-bjk964fp.js → chunk-h0qwdbem.js} +2 -2
  25. package/dist/cli/{chunk-1rkxzg3n.js → chunk-hktq4j5r.js} +15 -12
  26. package/dist/cli/{chunk-1rkxzg3n.js.map → chunk-hktq4j5r.js.map} +3 -3
  27. package/dist/cli/{chunk-enk76qd5.js → chunk-hn3g2b89.js} +9 -9
  28. package/dist/cli/{chunk-w6e455pz.js → chunk-j4nfgn2j.js} +1 -1
  29. package/dist/cli/{chunk-as09pafp.js → chunk-k45sq16q.js} +4 -4
  30. package/dist/cli/{chunk-as09pafp.js.map → chunk-k45sq16q.js.map} +3 -3
  31. package/dist/cli/{chunk-p8zg3a2h.js → chunk-kmswjxj9.js} +3 -3
  32. package/dist/cli/{chunk-xt0qvz1t.js → chunk-nqna8dzp.js} +17 -17
  33. package/dist/cli/{chunk-dyk3r7jw.js → chunk-p834rkcn.js} +5 -5
  34. package/dist/cli/{chunk-dpcahcrb.js → chunk-ppa6mg4d.js} +6 -6
  35. package/dist/cli/{chunk-79bzzkx4.js → chunk-q1wwrxzw.js} +2 -2
  36. package/dist/cli/{chunk-m9bp2hc1.js → chunk-qcyxkj44.js} +1 -1
  37. package/dist/cli/{chunk-dm4mg9bj.js → chunk-r5nm9w0c.js} +740 -685
  38. package/dist/cli/{chunk-dm4mg9bj.js.map → chunk-r5nm9w0c.js.map} +8 -6
  39. package/dist/cli/{chunk-a3ydrqny.js → chunk-rtvgmg2j.js} +2 -2
  40. package/dist/cli/{chunk-c69tw29j.js → chunk-t2h8pwy1.js} +4 -4
  41. package/dist/cli/{chunk-vzy9rh6y.js → chunk-x60e15st.js} +17 -17
  42. package/dist/cli/{chunk-mrvh4c88.js → chunk-x7bh6d9j.js} +4 -4
  43. package/dist/cli/{chunk-7tekknev.js → chunk-ydqzkhjn.js} +6 -6
  44. package/dist/cli/{chunk-gprwej7h.js → chunk-yg8ckc3g.js} +2 -2
  45. package/dist/cli/{chunk-8f8xw4x2.js → chunk-zxd5jrgx.js} +14 -4
  46. package/dist/cli/{chunk-8f8xw4x2.js.map → chunk-zxd5jrgx.js.map} +3 -3
  47. package/dist/cli/index.js +18 -18
  48. package/dist/types/core/config-input.d.ts +37 -5
  49. package/dist/types/core/data.d.ts +6 -2
  50. package/dist/types/core/mcp-clients.d.ts +47 -0
  51. package/dist/types/core/nav-diagnostics.d.ts +7 -0
  52. package/dist/types/core/schema.d.ts +55 -4
  53. package/dist/types/ratelimit/index.d.ts +2 -0
  54. package/dist/types/ratelimit/memory.d.ts +1 -1
  55. package/dist/types/ratelimit/schema.d.ts +40 -0
  56. package/dist/types/ratelimit/unkey.d.ts +48 -0
  57. package/docs/02-deployment.mdx +1 -0
  58. package/docs/configuration/index.mdx +5 -4
  59. package/docs/configuration/rate-limiting.mdx +20 -1
  60. package/docs/content/i18n.mdx +1 -1
  61. package/docs/discoverability/mcp.mdx +40 -8
  62. package/package.json +1 -1
  63. package/skills/blume-migrate/SKILL.md +2 -2
  64. package/skills/blume-migrate/references/docusaurus.md +2 -2
  65. package/skills/blume-migrate/references/gitbook.md +2 -2
  66. package/skills/blume-migrate/references/github-wiki.md +2 -2
  67. package/skills/blume-migrate/references/jekyll.md +2 -2
  68. package/skills/blume-migrate/references/mkdocs.md +2 -2
  69. package/skills/blume-migrate/references/nextra.md +3 -3
  70. package/skills/blume-migrate/references/readme.md +1 -1
  71. package/skills/blume-migrate/references/redocly.md +2 -2
  72. package/skills/blume-migrate/references/vitepress.md +2 -2
  73. package/skills/blume-migrate/references/vuepress.md +1 -1
  74. package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +1 -1
  75. package/src/astro/generate.ts +15 -5
  76. package/src/astro/templates.ts +4 -4
  77. package/src/cli/required-secrets.ts +0 -1
  78. package/src/components/layout/PageActions.astro +119 -34
  79. package/src/components/layout/RootLayout.astro +18 -2
  80. package/src/components/layout/SiteFooter.astro +31 -8
  81. package/src/core/config-input.ts +38 -4
  82. package/src/core/data.ts +10 -3
  83. package/src/core/mcp-clients.ts +87 -0
  84. package/src/core/nav-diagnostics.ts +25 -0
  85. package/src/core/schema.ts +93 -3
  86. package/src/openapi/proxy.ts +5 -1
  87. package/src/ratelimit/index.ts +2 -0
  88. package/src/ratelimit/memory.ts +1 -1
  89. package/src/ratelimit/runtime.ts +81 -0
  90. package/src/ratelimit/schema.ts +2 -0
  91. package/src/ratelimit/unkey.ts +61 -0
  92. package/src/registry/registry.ts +1 -1
  93. /package/dist/cli/{chunk-xgq4eytj.js.map → chunk-080mfch6.js.map} +0 -0
  94. /package/dist/cli/{chunk-br0n8n3e.js.map → chunk-1ch3ct3r.js.map} +0 -0
  95. /package/dist/cli/{chunk-49r370mb.js.map → chunk-2ssh60qh.js.map} +0 -0
  96. /package/dist/cli/{chunk-1cm0h819.js.map → chunk-34wap3ed.js.map} +0 -0
  97. /package/dist/cli/{chunk-fqy4n3q2.js.map → chunk-40vk8kgf.js.map} +0 -0
  98. /package/dist/cli/{chunk-96snx8yy.js.map → chunk-487b95qy.js.map} +0 -0
  99. /package/dist/cli/{chunk-kkrteb15.js.map → chunk-4zxh25hc.js.map} +0 -0
  100. /package/dist/cli/{chunk-cn3dy3hz.js.map → chunk-69rjf786.js.map} +0 -0
  101. /package/dist/cli/{chunk-t5xx74rd.js.map → chunk-9jqmq5hv.js.map} +0 -0
  102. /package/dist/cli/{chunk-8b489v1b.js.map → chunk-aafhwx74.js.map} +0 -0
  103. /package/dist/cli/{chunk-2nr29y95.js.map → chunk-b680sv30.js.map} +0 -0
  104. /package/dist/cli/{chunk-rkqae7kh.js.map → chunk-beb0z983.js.map} +0 -0
  105. /package/dist/cli/{chunk-th0pgaj5.js.map → chunk-c5bjxq8v.js.map} +0 -0
  106. /package/dist/cli/{chunk-wehv26ry.js.map → chunk-e1n41w9m.js.map} +0 -0
  107. /package/dist/cli/{chunk-frk2wdq1.js.map → chunk-etckvbrh.js.map} +0 -0
  108. /package/dist/cli/{chunk-ahc5gc9h.js.map → chunk-f260jdrn.js.map} +0 -0
  109. /package/dist/cli/{chunk-05f21r8h.js.map → chunk-fnxgtjeq.js.map} +0 -0
  110. /package/dist/cli/{chunk-vnbvhgmd.js.map → chunk-gxhcf2hs.js.map} +0 -0
  111. /package/dist/cli/{chunk-bjk964fp.js.map → chunk-h0qwdbem.js.map} +0 -0
  112. /package/dist/cli/{chunk-enk76qd5.js.map → chunk-hn3g2b89.js.map} +0 -0
  113. /package/dist/cli/{chunk-w6e455pz.js.map → chunk-j4nfgn2j.js.map} +0 -0
  114. /package/dist/cli/{chunk-p8zg3a2h.js.map → chunk-kmswjxj9.js.map} +0 -0
  115. /package/dist/cli/{chunk-xt0qvz1t.js.map → chunk-nqna8dzp.js.map} +0 -0
  116. /package/dist/cli/{chunk-dyk3r7jw.js.map → chunk-p834rkcn.js.map} +0 -0
  117. /package/dist/cli/{chunk-dpcahcrb.js.map → chunk-ppa6mg4d.js.map} +0 -0
  118. /package/dist/cli/{chunk-79bzzkx4.js.map → chunk-q1wwrxzw.js.map} +0 -0
  119. /package/dist/cli/{chunk-m9bp2hc1.js.map → chunk-qcyxkj44.js.map} +0 -0
  120. /package/dist/cli/{chunk-a3ydrqny.js.map → chunk-rtvgmg2j.js.map} +0 -0
  121. /package/dist/cli/{chunk-c69tw29j.js.map → chunk-t2h8pwy1.js.map} +0 -0
  122. /package/dist/cli/{chunk-vzy9rh6y.js.map → chunk-x60e15st.js.map} +0 -0
  123. /package/dist/cli/{chunk-mrvh4c88.js.map → chunk-x7bh6d9j.js.map} +0 -0
  124. /package/dist/cli/{chunk-7tekknev.js.map → chunk-ydqzkhjn.js.map} +0 -0
  125. /package/dist/cli/{chunk-gprwej7h.js.map → chunk-yg8ckc3g.js.map} +0 -0
@@ -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 icons. Unset, the site
491
- * has no footer (a `components.ts` `Footer` still renders).
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
  }
@@ -196,7 +196,11 @@ const followUpstream = async (args: {
196
196
  signal: AbortSignal;
197
197
  url: URL;
198
198
  }): Promise<Response> => {
199
- const response = await args.fetchImpl(args.url, {
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,
@@ -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";
@@ -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 `cloudflare()`.
38
+ * with `upstash()`, `cloudflare()`, or `unkey()`.
39
39
  */
40
40
  export const memory = (options: RateLimitOptions = {}): MemoryAdapter => ({
41
41
  kind: "memory",
@@ -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);
@@ -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
+ });
@@ -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, the theme toggle, and the Cookie settings link.",
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",