@rei-standard/amsg-server 2.6.0-next.3 → 2.6.0-next.4

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.
@@ -485,6 +485,81 @@ import {
485
485
  extractAssistantMessage,
486
486
  extractToolCallsFromDecision
487
487
  } from "@rei-standard/amsg-shared";
488
+
489
+ // src/server/lib/state-chunks.js
490
+ import { chunkReasoningByUtf8Bytes } from "@rei-standard/amsg-shared";
491
+ var STATE_CHUNK_SLICE_BYTES = 200 * 1024;
492
+ var DEFAULT_MAX_STATE_VALUE_BYTES = 5 * 1024 * 1024;
493
+ var INTERNAL_STATE_CHAR_RE = /[\u0000-\u001f]/;
494
+ var SEP = "";
495
+ var CHUNK_NS_PREFIX = `${SEP}amsg-chunks${SEP}`;
496
+ var ROOT_MARKER_PREFIX = `${SEP}amsg-chunked${SEP}v1${SEP}`;
497
+ function chunkNamespaceFor(namespace) {
498
+ return CHUNK_NS_PREFIX + namespace;
499
+ }
500
+ function chunkKeyFor(key, index) {
501
+ return `${key}${SEP}${index}`;
502
+ }
503
+ function chunkKeyPrefixFor(key) {
504
+ return `${key}${SEP}`;
505
+ }
506
+ function buildChunkedRootValue(chunkCount) {
507
+ return `${ROOT_MARKER_PREFIX}${chunkCount}`;
508
+ }
509
+ function parseChunkedRootCount(value) {
510
+ if (typeof value !== "string" || !value.startsWith(ROOT_MARKER_PREFIX)) return null;
511
+ const raw = value.slice(ROOT_MARKER_PREFIX.length);
512
+ if (!/^[1-9][0-9]*$/.test(raw)) return null;
513
+ return Number(raw);
514
+ }
515
+ function splitStateValue(value) {
516
+ return chunkReasoningByUtf8Bytes(value, STATE_CHUNK_SLICE_BYTES);
517
+ }
518
+ async function resolveClientStateEntries(rows, fetchChunkRows, decryptValue) {
519
+ let chunkMap = null;
520
+ const loadChunks = async () => {
521
+ if (chunkMap === null) {
522
+ const chunkRows = await fetchChunkRows();
523
+ chunkMap = new Map(chunkRows.map((row) => [row.key, row]));
524
+ }
525
+ return chunkMap;
526
+ };
527
+ const entries = [];
528
+ for (const row of rows) {
529
+ const count = parseChunkedRootCount(row.value);
530
+ if (count === null) {
531
+ entries.push({
532
+ namespace: row.namespace,
533
+ key: row.key,
534
+ value: await decryptValue(row.value),
535
+ updatedAt: row.updated_at
536
+ });
537
+ continue;
538
+ }
539
+ const map = await loadChunks();
540
+ const chunkRows = [];
541
+ let intact = true;
542
+ for (let i = 0; i < count; i++) {
543
+ const chunk = map.get(chunkKeyFor(row.key, i));
544
+ if (!chunk || chunk.updated_at !== row.updated_at) {
545
+ intact = false;
546
+ break;
547
+ }
548
+ chunkRows.push(chunk);
549
+ }
550
+ if (!intact) continue;
551
+ const parts = await Promise.all(chunkRows.map((chunk) => decryptValue(chunk.value)));
552
+ entries.push({
553
+ namespace: row.namespace,
554
+ key: row.key,
555
+ value: parts.join(""),
556
+ updatedAt: row.updated_at
557
+ });
558
+ }
559
+ return entries;
560
+ }
561
+
562
+ // src/server/lib/agentic-fire.js
488
563
  var DEFAULT_MAX_TOOL_ITERATIONS = 5;
489
564
  var DEFAULT_TOTAL_TIMEOUT_MS = 24e4;
490
565
  var SLEEP_BETWEEN_MESSAGES_MS = 1500;
@@ -551,18 +626,19 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
551
626
  }
552
627
  if (!ctx.db || typeof ctx.db.getClientState !== "function") return [];
553
628
  const rows = await ctx.db.getClientState(task.user_id, namespace);
554
- return Promise.all(rows.map(async (row) => ({
555
- namespace: row.namespace,
556
- key: row.key,
557
- value: await decryptFromStorage(row.value, userKey),
558
- updatedAt: row.updated_at
559
- })));
629
+ return resolveClientStateEntries(
630
+ rows,
631
+ () => ctx.db.getClientState(task.user_id, chunkNamespaceFor(namespace)),
632
+ (value) => decryptFromStorage(value, userKey)
633
+ );
560
634
  };
635
+ const scratch = {};
561
636
  const fireCtx = Object.freeze({
562
637
  task: buildHookTask(task, decryptedPayload),
563
638
  userId: task.user_id,
564
639
  readState,
565
- now: new Date(nowFn())
640
+ now: new Date(nowFn()),
641
+ scratch
566
642
  });
567
643
  const before = await hooks.onBeforeFire(fireCtx);
568
644
  if (before == null) return { handled: false };
@@ -597,7 +673,8 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
597
673
  contactName: decryptedPayload.contactName,
598
674
  avatarUrl: decryptedPayload.avatarUrl || void 0,
599
675
  charId: decryptedPayload.charId,
600
- metadata: decryptedPayload.metadata
676
+ metadata: decryptedPayload.metadata,
677
+ scratch
601
678
  });
602
679
  const decision = await hooks.onLLMOutput(sessionCtx);
603
680
  assertValidDecision(decision, { inlineToolCalls: true });
@@ -1439,6 +1516,9 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
1439
1516
  "created_at",
1440
1517
  "updated_at"
1441
1518
  ]);
1519
+ function escapeLikePrefix(prefix) {
1520
+ return prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
1521
+ }
1442
1522
  var D1Adapter = class {
1443
1523
  /** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */
1444
1524
  constructor(db) {
@@ -1620,6 +1700,10 @@ var D1Adapter = class {
1620
1700
  * than the stored row (updatedAt strictly lower) is skipped; equal or
1621
1701
  * newer overwrites. Values arrive pre-encrypted (the handler encrypts).
1622
1702
  *
1703
+ * `cleanups` 是分块存储的清理项(见 lib/state-chunks.js):在同一 batch 里
1704
+ * 先于 upsert 执行,按 (namespace, key 前缀) 删掉旧写入留下的切片行;
1705
+ * `updated_at <= ?` 条件保证陈旧批次删不动更新写入的行。
1706
+ *
1623
1707
  * Uses D1's batch() — one network round trip for the whole set (implicit
1624
1708
  * transaction). The client calls this endpoint inside its few-seconds
1625
1709
  * background window, so N sequential round trips could eat the whole
@@ -1628,36 +1712,44 @@ var D1Adapter = class {
1628
1712
  *
1629
1713
  * @param {string} userId
1630
1714
  * @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
1631
- * @returns {Promise<{ upserted: number, skipped: number }>}
1715
+ * @param {Array<{ namespace: string, keyPrefix: string, updatedAt: number }>} [cleanups]
1716
+ * @returns {Promise<{ upserted: number, skipped: number, outcomes: boolean[] }>}
1717
+ * `outcomes[i]` 对应 entries[i] 是否真的写入(changes > 0)。
1632
1718
  */
1633
- async upsertClientState(userId, entries) {
1719
+ async upsertClientState(userId, entries, cleanups = []) {
1634
1720
  const UPSERT_SQL = `INSERT INTO client_state (user_id, namespace, key, value, updated_at)
1635
1721
  VALUES (?, ?, ?, ?, ?)
1636
1722
  ON CONFLICT (user_id, namespace, key) DO UPDATE SET
1637
1723
  value = excluded.value,
1638
1724
  updated_at = excluded.updated_at
1639
1725
  WHERE excluded.updated_at >= client_state.updated_at`;
1726
+ const CLEANUP_SQL = `DELETE FROM client_state
1727
+ WHERE user_id = ? AND namespace = ? AND key LIKE ? ESCAPE '\\' AND updated_at <= ?`;
1728
+ const buildStatements = () => [
1729
+ ...cleanups.map(
1730
+ (c) => this._db.prepare(CLEANUP_SQL).bind(userId, c.namespace, `${escapeLikePrefix(c.keyPrefix)}%`, c.updatedAt)
1731
+ ),
1732
+ ...entries.map(
1733
+ (entry) => this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
1734
+ )
1735
+ ];
1640
1736
  let results;
1641
1737
  if (typeof this._db.batch === "function") {
1642
- const statements = entries.map(
1643
- (entry) => this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
1644
- );
1645
- results = await this._db.batch(statements);
1738
+ results = await this._db.batch(buildStatements());
1646
1739
  } else {
1647
1740
  results = [];
1648
- for (const entry of entries) {
1649
- results.push(
1650
- await this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt).run()
1651
- );
1741
+ for (const stmt of buildStatements()) {
1742
+ results.push(await stmt.run());
1652
1743
  }
1653
1744
  }
1745
+ const outcomes = results.slice(cleanups.length).map((res) => res.meta.changes > 0);
1654
1746
  let upserted = 0;
1655
1747
  let skipped = 0;
1656
- for (const res of results) {
1657
- if (res.meta.changes > 0) upserted++;
1748
+ for (const wrote of outcomes) {
1749
+ if (wrote) upserted++;
1658
1750
  else skipped++;
1659
1751
  }
1660
- return { upserted, skipped };
1752
+ return { upserted, skipped, outcomes };
1661
1753
  }
1662
1754
  /**
1663
1755
  * All entries of one namespace (values still encrypted).
@@ -1799,7 +1891,6 @@ function createVapidPublicKeyHandler(ctx) {
1799
1891
  }
1800
1892
 
1801
1893
  // src/server/handlers/client-state.js
1802
- var MAX_STATE_VALUE_BYTES = 200 * 1024;
1803
1894
  var MAX_STATE_ENTRIES_PER_REQUEST = 200;
1804
1895
  var MAX_NAMESPACE_CHARS = 128;
1805
1896
  var MAX_KEY_CHARS = 256;
@@ -1814,23 +1905,39 @@ function requireUserId(headers) {
1814
1905
  if (!isValidUUIDv4(userId)) return { error: err(400, "INVALID_USER_ID_FORMAT", "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F") };
1815
1906
  return { userId };
1816
1907
  }
1817
- function validateEntry(entry, index) {
1818
- if (!isPlainObject(entry)) return err(400, "INVALID_STATE_ENTRY", `entries[${index}] \u5FC5\u987B\u662F\u5BF9\u8C61`);
1908
+ function rejectEntry(entry, index, code, message, extra) {
1909
+ const rejection = { index, code, message, ...extra || {} };
1910
+ if (entry && typeof entry === "object") {
1911
+ if (typeof entry.namespace === "string") rejection.namespace = entry.namespace;
1912
+ if (typeof entry.key === "string") rejection.key = entry.key;
1913
+ }
1914
+ return rejection;
1915
+ }
1916
+ function validateEntry(entry, index, maxValueBytes) {
1917
+ if (!isPlainObject(entry)) {
1918
+ return rejectEntry(entry, index, "INVALID_STATE_ENTRY", `entries[${index}] \u5FC5\u987B\u662F\u5BF9\u8C61`);
1919
+ }
1819
1920
  if (typeof entry.namespace !== "string" || !entry.namespace.trim() || entry.namespace.length > MAX_NAMESPACE_CHARS) {
1820
- return err(400, "INVALID_STATE_NAMESPACE", `entries[${index}].namespace \u5FC5\u987B\u662F 1-${MAX_NAMESPACE_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
1921
+ return rejectEntry(entry, index, "INVALID_STATE_NAMESPACE", `entries[${index}].namespace \u5FC5\u987B\u662F 1-${MAX_NAMESPACE_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
1922
+ }
1923
+ if (INTERNAL_STATE_CHAR_RE.test(entry.namespace)) {
1924
+ return rejectEntry(entry, index, "INVALID_STATE_NAMESPACE", `entries[${index}].namespace \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09`);
1821
1925
  }
1822
1926
  if (typeof entry.key !== "string" || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
1823
- return err(400, "INVALID_STATE_KEY", `entries[${index}].key \u5FC5\u987B\u662F 1-${MAX_KEY_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
1927
+ return rejectEntry(entry, index, "INVALID_STATE_KEY", `entries[${index}].key \u5FC5\u987B\u662F 1-${MAX_KEY_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
1928
+ }
1929
+ if (INTERNAL_STATE_CHAR_RE.test(entry.key)) {
1930
+ return rejectEntry(entry, index, "INVALID_STATE_KEY", `entries[${index}].key \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09`);
1824
1931
  }
1825
1932
  if (typeof entry.value !== "string") {
1826
- return err(400, "INVALID_STATE_VALUE", `entries[${index}].value \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF08\u5BBF\u4E3B\u81EA\u884C\u5E8F\u5217\u5316\uFF09`);
1933
+ return rejectEntry(entry, index, "INVALID_STATE_VALUE", `entries[${index}].value \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF08\u5BBF\u4E3B\u81EA\u884C\u5E8F\u5217\u5316\uFF09`);
1827
1934
  }
1828
1935
  const bytes = utf82.encode(entry.value).length;
1829
- if (bytes > MAX_STATE_VALUE_BYTES) {
1830
- return err(413, "STATE_VALUE_TOO_LARGE", `entries[${index}].value \u8D85\u8FC7\u5355\u6761\u4E0A\u9650`, { index, key: entry.key, bytes, maxBytes: MAX_STATE_VALUE_BYTES });
1936
+ if (bytes > maxValueBytes) {
1937
+ return rejectEntry(entry, index, "STATE_VALUE_TOO_LARGE", `entries[${index}].value \u8D85\u8FC7\u5355\u6761\u603B\u4E0A\u9650`, { bytes, maxBytes: maxValueBytes });
1831
1938
  }
1832
1939
  if (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0) {
1833
- return err(400, "INVALID_STATE_UPDATED_AT", `entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
1940
+ return rejectEntry(entry, index, "INVALID_STATE_UPDATED_AT", `entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
1834
1941
  }
1835
1942
  return null;
1836
1943
  }
@@ -1868,21 +1975,70 @@ function createClientStateHandler(ctx) {
1868
1975
  if (entries.length > MAX_STATE_ENTRIES_PER_REQUEST) {
1869
1976
  return err(400, "TOO_MANY_STATE_ENTRIES", `\u5355\u6B21\u6700\u591A ${MAX_STATE_ENTRIES_PER_REQUEST} \u6761`, { count: entries.length });
1870
1977
  }
1978
+ const maxValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0 ? ctx.maxStateValueBytes : DEFAULT_MAX_STATE_VALUE_BYTES;
1979
+ const accepted = [];
1980
+ const rejected = [];
1871
1981
  for (let i = 0; i < entries.length; i++) {
1872
- const invalid = validateEntry(entries[i], i);
1873
- if (invalid) return invalid;
1982
+ const rejection = validateEntry(entries[i], i, maxValueBytes);
1983
+ if (rejection) rejected.push(rejection);
1984
+ else accepted.push(entries[i]);
1874
1985
  }
1875
1986
  if (typeof db.upsertClientState !== "function") {
1876
1987
  return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
1877
1988
  }
1878
- const encryptedEntries = await Promise.all(entries.map(async (entry) => ({
1879
- namespace: entry.namespace,
1880
- key: entry.key,
1881
- value: await encryptForStorage(entry.value, userKey),
1882
- updatedAt: entry.updatedAt
1883
- })));
1884
- const { upserted, skipped } = await db.upsertClientState(userId, encryptedEntries);
1885
- return { status: 200, body: { success: true, data: { upserted, skipped } } };
1989
+ const physicalRows = [];
1990
+ const cleanups = [];
1991
+ const rootRowIndexes = [];
1992
+ for (const entry of accepted) {
1993
+ cleanups.push({
1994
+ namespace: chunkNamespaceFor(entry.namespace),
1995
+ keyPrefix: chunkKeyPrefixFor(entry.key),
1996
+ updatedAt: entry.updatedAt
1997
+ });
1998
+ rootRowIndexes.push(physicalRows.length);
1999
+ if (utf82.encode(entry.value).length <= STATE_CHUNK_SLICE_BYTES) {
2000
+ physicalRows.push({
2001
+ namespace: entry.namespace,
2002
+ key: entry.key,
2003
+ value: await encryptForStorage(entry.value, userKey),
2004
+ updatedAt: entry.updatedAt
2005
+ });
2006
+ } else {
2007
+ const slices = splitStateValue(entry.value);
2008
+ physicalRows.push({
2009
+ namespace: entry.namespace,
2010
+ key: entry.key,
2011
+ value: buildChunkedRootValue(slices.length),
2012
+ updatedAt: entry.updatedAt
2013
+ });
2014
+ const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
2015
+ for (let c = 0; c < encryptedSlices.length; c++) {
2016
+ physicalRows.push({
2017
+ namespace: chunkNamespaceFor(entry.namespace),
2018
+ key: chunkKeyFor(entry.key, c),
2019
+ value: encryptedSlices[c],
2020
+ updatedAt: entry.updatedAt
2021
+ });
2022
+ }
2023
+ }
2024
+ }
2025
+ let upserted = 0;
2026
+ let skipped = 0;
2027
+ if (physicalRows.length > 0) {
2028
+ const result = await db.upsertClientState(userId, physicalRows, cleanups);
2029
+ if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
2030
+ for (const rootIndex of rootRowIndexes) {
2031
+ if (result.outcomes[rootIndex]) upserted++;
2032
+ else skipped++;
2033
+ }
2034
+ } else {
2035
+ upserted = result.upserted;
2036
+ skipped = result.skipped;
2037
+ }
2038
+ }
2039
+ const data = { upserted, skipped };
2040
+ if (rejected.length > 0) data.rejected = rejected;
2041
+ return { status: 200, body: { success: true, data } };
1886
2042
  }
1887
2043
  async function GET(url, headers) {
1888
2044
  const tenantResult = await ctx.tenantManager.resolveTenant(headers);
@@ -1893,17 +2049,19 @@ function createClientStateHandler(ctx) {
1893
2049
  const { userId } = gate;
1894
2050
  const namespace = new URL(url, "https://dummy").searchParams.get("namespace") || "";
1895
2051
  if (!namespace.trim()) return err(400, "NAMESPACE_REQUIRED", "\u5FC5\u987B\u63D0\u4F9B namespace \u67E5\u8BE2\u53C2\u6570");
2052
+ if (INTERNAL_STATE_CHAR_RE.test(namespace)) {
2053
+ return err(400, "INVALID_STATE_NAMESPACE", "namespace \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09");
2054
+ }
1896
2055
  if (typeof db.getClientState !== "function") {
1897
2056
  return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
1898
2057
  }
1899
2058
  const userKey = await deriveUserEncryptionKey(userId, masterKey);
1900
2059
  const rows = await db.getClientState(userId, namespace);
1901
- const decrypted = await Promise.all(rows.map(async (row) => ({
1902
- namespace: row.namespace,
1903
- key: row.key,
1904
- value: await decryptFromStorage(row.value, userKey),
1905
- updatedAt: row.updated_at
1906
- })));
2060
+ const decrypted = await resolveClientStateEntries(
2061
+ rows,
2062
+ () => db.getClientState(userId, chunkNamespaceFor(namespace)),
2063
+ (value) => decryptFromStorage(value, userKey)
2064
+ );
1907
2065
  const encryptedResponse = await encryptPayload({ namespace, entries: decrypted }, userKey);
1908
2066
  return { status: 200, body: { success: true, encrypted: true, version: 1, data: encryptedResponse } };
1909
2067
  }
@@ -1923,6 +2081,33 @@ function createClientStateHandler(ctx) {
1923
2081
  return { PUT, GET, DELETE };
1924
2082
  }
1925
2083
 
2084
+ // src/server/lib/version.js
2085
+ var SERVER_VERSION = true ? "2.6.0-next.4" : "0.0.0-dev";
2086
+
2087
+ // src/server/handlers/capabilities.js
2088
+ var SERVER_FEATURES = Object.freeze([
2089
+ "client-state",
2090
+ "client-state-chunking",
2091
+ "client-state-partial-failure",
2092
+ "agentic-hooks",
2093
+ "agentic-scratch",
2094
+ "vapid-public-key"
2095
+ ]);
2096
+ function createCapabilitiesHandler(ctx) {
2097
+ async function GET(url, headers) {
2098
+ const effectiveHeaders = headers || url || {};
2099
+ const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
2100
+ if (!tenantResult.ok) {
2101
+ return tenantResult.error;
2102
+ }
2103
+ return {
2104
+ status: 200,
2105
+ body: { success: true, serverVersion: SERVER_VERSION, features: [...SERVER_FEATURES] }
2106
+ };
2107
+ }
2108
+ return { GET };
2109
+ }
2110
+
1926
2111
  // src/server/single-user.js
1927
2112
  function createSingleUserServer(config) {
1928
2113
  if (!config || !config.db) throw new Error("[amsg-server single-user] config.db is required");
@@ -1946,7 +2131,8 @@ function createSingleUserServer(config) {
1946
2131
  // the agentic path too.
1947
2132
  hooks: config.hooks || null,
1948
2133
  maxToolIterations: config.maxToolIterations,
1949
- totalTimeoutMs: config.totalTimeoutMs
2134
+ totalTimeoutMs: config.totalTimeoutMs,
2135
+ maxStateValueBytes: config.maxStateValueBytes
1950
2136
  };
1951
2137
  return {
1952
2138
  ctx,
@@ -1958,7 +2144,8 @@ function createSingleUserServer(config) {
1958
2144
  cancelMessage: createCancelMessageHandler(ctx),
1959
2145
  messages: createMessagesHandler(ctx),
1960
2146
  vapidPublicKey: createVapidPublicKeyHandler(ctx),
1961
- clientState: createClientStateHandler(ctx)
2147
+ clientState: createClientStateHandler(ctx),
2148
+ capabilities: createCapabilitiesHandler(ctx)
1962
2149
  }
1963
2150
  };
1964
2151
  }
@@ -2224,6 +2411,8 @@ function createSingleUserCloudflareWorker(buildConfig) {
2224
2411
  result = await server.handlers.cancelMessage.DELETE(url, headers);
2225
2412
  } else if (method === "GET" && pathname.endsWith("/vapid-public-key")) {
2226
2413
  result = await server.handlers.vapidPublicKey.GET(url, headers);
2414
+ } else if (method === "GET" && pathname.endsWith("/capabilities")) {
2415
+ result = await server.handlers.capabilities.GET(url, headers);
2227
2416
  } else if (method === "PUT" && pathname.endsWith("/client-state")) {
2228
2417
  result = await server.handlers.clientState.PUT(headers, await request.text());
2229
2418
  } else if (method === "GET" && pathname.endsWith("/client-state")) {