@rei-standard/amsg-server 2.6.0-next.6 → 2.6.0-next.7
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/README.md +26 -0
- package/dist/{chunk-JODQV7MD.mjs → chunk-I7EUMYQ4.mjs} +188 -74
- package/dist/{chunk-WFFIWXM5.cjs → chunk-YQPWXPIN.cjs} +189 -75
- package/dist/{cloudflare-qy1YWQcp.d.cts → cloudflare-BBPmRS2L.d.cts} +328 -83
- package/dist/{cloudflare-qy1YWQcp.d.ts → cloudflare-BBPmRS2L.d.ts} +328 -83
- package/dist/cloudflare.cjs +10 -2
- package/dist/cloudflare.d.cts +1 -1
- package/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.mjs +9 -1
- package/dist/index.cjs +19 -11
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,10 +104,32 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
|
|
|
104
104
|
- 限制:每项 ≤ 200 字符,数组 ≤ 10 项;非法或无法 `new RegExp(...)` 通过 → `400 INVALID_PARAMETERS`(schedule)/ `400 INVALID_UPDATE_DATA`(update)。
|
|
105
105
|
- `update-message` 显式传 `splitPattern: null` 可重置回默认;不传则保留原值。
|
|
106
106
|
|
|
107
|
+
## 一条 Web Push 能塞多少
|
|
108
|
+
|
|
109
|
+
推送服务(FCM / APNs / Mozilla autopush)限的是**加密后** body 的 4096 字节,超了当场 413 拒收,用户什么也收不到。明文额度要把 aes128gcm 的固定开销减掉——header 86(salt 16 + record size 4 + keyid 长度 1 + 应用服务器公钥 65)+ 填充分隔符 1 + GCM auth tag 16 = 103 字节——所以**一条 push 的 payload 上限是 3993 字节**,按 UTF-8 字节算,不是字符数。
|
|
110
|
+
|
|
111
|
+
`sendWebPush` 会在发出去之前挡下超限的 payload,抛出 `err.code === 'PUSH_PAYLOAD_TOO_LARGE'` 的错误,消息里带实际字节数和上限。
|
|
112
|
+
|
|
113
|
+
组 payload 之前想自己做预算,用导出的常量和工具函数,别写死魔法数字:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
import { MAX_PUSH_PAYLOAD_BYTES, measurePushPayload } from '@rei-standard/amsg-server';
|
|
117
|
+
|
|
118
|
+
const { bytes, remainingBytes, withinLimit } = measurePushPayload(JSON.stringify(push));
|
|
119
|
+
// remainingBytes = 还能再塞多少字节(已超限时为负)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
装不下的内容(长文、附件详情)建议走旁路:正文存进 `client_state`,push 里只带一个引用键,客户端上线后用 `GET /client-state` 取回。单用户 Worker 的 fire-time hook 用 `ctx.writeState()` 写,见 [`examples/cloudflare-single-user/README.md`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md)。
|
|
123
|
+
|
|
107
124
|
## 导出(新增)
|
|
108
125
|
|
|
109
126
|
- `validateLlmMessagesArray(messages)` — 同步预校验 messages 数组,返回 `string | null`(错误信息 / 通过)。和 `@rei-standard/amsg-instant` 的校验规则字节级一致。
|
|
110
127
|
- `validateSplitPattern(value)` — 同步预校验 splitPattern(string / string[] / null),返回 `string | null`。
|
|
128
|
+
- `MAX_PUSH_PAYLOAD_BYTES` — 一条 push 的明文上限,3993 字节。
|
|
129
|
+
- `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES` — 推送服务的密文 body 上限(4096)与 aes128gcm 固定开销(103),上面那个数就是两者相减。
|
|
130
|
+
- `measurePushPayload(payload)` — 量一段 payload 的字节数与剩余额度,返回 `{ bytes, maxBytes, remainingBytes, withinLimit }`。
|
|
131
|
+
|
|
132
|
+
以上四个在包根和 `@rei-standard/amsg-server/cloudflare` 两个入口都有。
|
|
111
133
|
|
|
112
134
|
## 一体化初始化流程
|
|
113
135
|
|
|
@@ -150,6 +172,10 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
|
|
|
150
172
|
- `encryptForStorage`
|
|
151
173
|
- `decryptFromStorage`
|
|
152
174
|
- `validateScheduleMessagePayload`
|
|
175
|
+
- `measurePushPayload`
|
|
176
|
+
- `MAX_PUSH_PAYLOAD_BYTES`
|
|
177
|
+
- `WEB_PUSH_MAX_BODY_BYTES`
|
|
178
|
+
- `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES`
|
|
153
179
|
- `isValidISO8601`
|
|
154
180
|
- `isValidUrl`
|
|
155
181
|
- `isValidUUID`
|
|
@@ -559,6 +559,79 @@ async function resolveClientStateEntries(rows, fetchChunkRows, decryptValue) {
|
|
|
559
559
|
return entries;
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
+
// src/server/lib/client-state-store.js
|
|
563
|
+
var utf82 = new TextEncoder();
|
|
564
|
+
var MAX_STATE_ENTRIES_PER_BATCH = 200;
|
|
565
|
+
var MAX_NAMESPACE_CHARS = 128;
|
|
566
|
+
var MAX_KEY_CHARS = 256;
|
|
567
|
+
function stateValueBytes(value) {
|
|
568
|
+
return utf82.encode(value).length;
|
|
569
|
+
}
|
|
570
|
+
async function writeClientStateEntries({ db, userId, userKey, entries }) {
|
|
571
|
+
const physicalRows = [];
|
|
572
|
+
const cleanups = [];
|
|
573
|
+
const rootRowIndexes = [];
|
|
574
|
+
let deleted = 0;
|
|
575
|
+
for (const entry of entries) {
|
|
576
|
+
cleanups.push({
|
|
577
|
+
namespace: chunkNamespaceFor(entry.namespace),
|
|
578
|
+
keyPrefix: chunkKeyPrefixFor(entry.key),
|
|
579
|
+
updatedAt: entry.updatedAt
|
|
580
|
+
});
|
|
581
|
+
if (entry.value === null) {
|
|
582
|
+
cleanups.push({
|
|
583
|
+
namespace: entry.namespace,
|
|
584
|
+
key: entry.key,
|
|
585
|
+
updatedAt: entry.updatedAt
|
|
586
|
+
});
|
|
587
|
+
deleted++;
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
rootRowIndexes.push(physicalRows.length);
|
|
591
|
+
if (stateValueBytes(entry.value) <= STATE_CHUNK_SLICE_BYTES) {
|
|
592
|
+
physicalRows.push({
|
|
593
|
+
namespace: entry.namespace,
|
|
594
|
+
key: entry.key,
|
|
595
|
+
value: await encryptForStorage(entry.value, userKey),
|
|
596
|
+
updatedAt: entry.updatedAt
|
|
597
|
+
});
|
|
598
|
+
} else {
|
|
599
|
+
const slices = splitStateValue(entry.value);
|
|
600
|
+
physicalRows.push({
|
|
601
|
+
namespace: entry.namespace,
|
|
602
|
+
key: entry.key,
|
|
603
|
+
value: buildChunkedRootValue(slices.length),
|
|
604
|
+
updatedAt: entry.updatedAt
|
|
605
|
+
});
|
|
606
|
+
const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
|
|
607
|
+
for (let c = 0; c < encryptedSlices.length; c++) {
|
|
608
|
+
physicalRows.push({
|
|
609
|
+
namespace: chunkNamespaceFor(entry.namespace),
|
|
610
|
+
key: chunkKeyFor(entry.key, c),
|
|
611
|
+
value: encryptedSlices[c],
|
|
612
|
+
updatedAt: entry.updatedAt
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (physicalRows.length === 0 && cleanups.length === 0) {
|
|
618
|
+
return { upserted: 0, skipped: 0, deleted: 0 };
|
|
619
|
+
}
|
|
620
|
+
const result = await db.upsertClientState(userId, physicalRows, cleanups);
|
|
621
|
+
let upserted = 0;
|
|
622
|
+
let skipped = 0;
|
|
623
|
+
if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
|
|
624
|
+
for (const rootIndex of rootRowIndexes) {
|
|
625
|
+
if (result.outcomes[rootIndex]) upserted++;
|
|
626
|
+
else skipped++;
|
|
627
|
+
}
|
|
628
|
+
} else {
|
|
629
|
+
upserted = result.upserted;
|
|
630
|
+
skipped = result.skipped;
|
|
631
|
+
}
|
|
632
|
+
return { upserted, skipped, deleted };
|
|
633
|
+
}
|
|
634
|
+
|
|
562
635
|
// src/server/lib/agentic-fire.js
|
|
563
636
|
var DEFAULT_MAX_TOOL_ITERATIONS = 5;
|
|
564
637
|
var DEFAULT_TOTAL_TIMEOUT_MS = 24e4;
|
|
@@ -632,11 +705,64 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
|
|
|
632
705
|
(value) => decryptFromStorage(value, userKey)
|
|
633
706
|
);
|
|
634
707
|
};
|
|
708
|
+
const maxStateValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0 ? ctx.maxStateValueBytes : DEFAULT_MAX_STATE_VALUE_BYTES;
|
|
709
|
+
const writeState = async (namespace, entries) => {
|
|
710
|
+
if (typeof namespace !== "string" || !namespace.trim()) {
|
|
711
|
+
throw new TypeError("writeState(namespace, entries) requires a non-empty string namespace");
|
|
712
|
+
}
|
|
713
|
+
if (namespace.length > MAX_NAMESPACE_CHARS || INTERNAL_STATE_CHAR_RE.test(namespace)) {
|
|
714
|
+
throw new TypeError(
|
|
715
|
+
`writeState: namespace \u5FC5\u987B\u662F 1-${MAX_NAMESPACE_CHARS} \u5B57\u7B26\u4E14\u4E0D\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
if (!Array.isArray(entries)) {
|
|
719
|
+
throw new TypeError("writeState(namespace, entries) requires an array of { key, value }");
|
|
720
|
+
}
|
|
721
|
+
if (entries.length === 0) return { upserted: 0, skipped: 0, deleted: 0 };
|
|
722
|
+
if (entries.length > MAX_STATE_ENTRIES_PER_BATCH) {
|
|
723
|
+
throw new RangeError(`writeState: \u5355\u6B21\u6700\u591A ${MAX_STATE_ENTRIES_PER_BATCH} \u6761\uFF0C\u6536\u5230 ${entries.length} \u6761`);
|
|
724
|
+
}
|
|
725
|
+
if (!ctx.db || typeof ctx.db.upsertClientState !== "function") {
|
|
726
|
+
throw new Error("AGENTIC_STATE_WRITE_UNSUPPORTED: \u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state \u5199\u5165");
|
|
727
|
+
}
|
|
728
|
+
const now = nowFn();
|
|
729
|
+
const normalized2 = entries.map((entry, index) => {
|
|
730
|
+
if (!entry || typeof entry !== "object") {
|
|
731
|
+
throw new TypeError(`writeState: entries[${index}] \u5FC5\u987B\u662F\u5BF9\u8C61`);
|
|
732
|
+
}
|
|
733
|
+
if (typeof entry.key !== "string" || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
|
|
734
|
+
throw new TypeError(`writeState: entries[${index}].key \u5FC5\u987B\u662F 1-${MAX_KEY_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
|
|
735
|
+
}
|
|
736
|
+
if (INTERNAL_STATE_CHAR_RE.test(entry.key)) {
|
|
737
|
+
throw new TypeError(`writeState: entries[${index}].key \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09`);
|
|
738
|
+
}
|
|
739
|
+
if (entry.value !== null && typeof entry.value !== "string") {
|
|
740
|
+
throw new TypeError(`writeState: entries[${index}].value \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF08\u5BBF\u4E3B\u81EA\u884C\u5E8F\u5217\u5316\uFF09\uFF0C\u6216 null \u8868\u793A\u5220\u9664`);
|
|
741
|
+
}
|
|
742
|
+
if (typeof entry.value === "string") {
|
|
743
|
+
const bytes = stateValueBytes(entry.value);
|
|
744
|
+
if (bytes > maxStateValueBytes) {
|
|
745
|
+
throw new RangeError(`writeState: entries[${index}].value \u4E3A ${bytes} \u5B57\u8282\uFF0C\u8D85\u8FC7\u5355\u6761\u4E0A\u9650 ${maxStateValueBytes} \u5B57\u8282`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (entry.updatedAt !== void 0 && (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0)) {
|
|
749
|
+
throw new TypeError(`writeState: entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
namespace,
|
|
753
|
+
key: entry.key,
|
|
754
|
+
value: entry.value,
|
|
755
|
+
updatedAt: entry.updatedAt ?? now
|
|
756
|
+
};
|
|
757
|
+
});
|
|
758
|
+
return writeClientStateEntries({ db: ctx.db, userId: task.user_id, userKey, entries: normalized2 });
|
|
759
|
+
};
|
|
635
760
|
const scratch = {};
|
|
636
761
|
const fireCtx = Object.freeze({
|
|
637
762
|
task: buildHookTask(task, decryptedPayload),
|
|
638
763
|
userId: task.user_id,
|
|
639
764
|
readState,
|
|
765
|
+
writeState,
|
|
640
766
|
now: new Date(nowFn()),
|
|
641
767
|
scratch
|
|
642
768
|
});
|
|
@@ -668,16 +794,20 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
|
|
|
668
794
|
);
|
|
669
795
|
const assistantMessage = extractAssistantMessage(llmResponse);
|
|
670
796
|
messages = [...messages, assistantMessage];
|
|
671
|
-
const sessionCtx =
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
797
|
+
const sessionCtx = Object.freeze({
|
|
798
|
+
...buildSessionContext({
|
|
799
|
+
sessionId,
|
|
800
|
+
messages,
|
|
801
|
+
llmResponse,
|
|
802
|
+
iteration,
|
|
803
|
+
contactName: decryptedPayload.contactName,
|
|
804
|
+
avatarUrl: decryptedPayload.avatarUrl || void 0,
|
|
805
|
+
charId: decryptedPayload.charId,
|
|
806
|
+
metadata: decryptedPayload.metadata,
|
|
807
|
+
scratch
|
|
808
|
+
}),
|
|
809
|
+
readState,
|
|
810
|
+
writeState
|
|
681
811
|
});
|
|
682
812
|
const decision = await hooks.onLLMOutput(sessionCtx);
|
|
683
813
|
assertValidDecision(decision, { inlineToolCalls: true });
|
|
@@ -1796,9 +1926,15 @@ var D1Adapter = class {
|
|
|
1796
1926
|
* than the stored row (updatedAt strictly lower) is skipped; equal or
|
|
1797
1927
|
* newer overwrites. Values arrive pre-encrypted (the handler encrypts).
|
|
1798
1928
|
*
|
|
1799
|
-
* `cleanups`
|
|
1800
|
-
*
|
|
1801
|
-
* `
|
|
1929
|
+
* `cleanups` 是删除项:在同一 batch 里先于 upsert 执行,`updated_at <= ?`
|
|
1930
|
+
* 条件保证陈旧批次删不动更新写入的行。两种形态——
|
|
1931
|
+
* - `{ namespace, keyPrefix, updatedAt }` 删 key 前缀下的所有行,用来清掉
|
|
1932
|
+
* 大值旧写入留下的切片行(见 lib/state-chunks.js);
|
|
1933
|
+
* - `{ namespace, key, updatedAt }` 删这一个 key,用来删整条状态(前缀会
|
|
1934
|
+
* 连带删掉同前缀的兄弟 key,删单条必须走精确匹配)。
|
|
1935
|
+
*
|
|
1936
|
+
* 删掉一整条状态就是这两种各来一条(切片行走前缀、根行走精确 key),
|
|
1937
|
+
* `entries` 传空数组即可——见 lib/client-state-store.js。
|
|
1802
1938
|
*
|
|
1803
1939
|
* Uses D1's batch() — one network round trip for the whole set (implicit
|
|
1804
1940
|
* transaction). The client calls this endpoint inside its few-seconds
|
|
@@ -1808,7 +1944,7 @@ var D1Adapter = class {
|
|
|
1808
1944
|
*
|
|
1809
1945
|
* @param {string} userId
|
|
1810
1946
|
* @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
|
|
1811
|
-
* @param {Array<{ namespace: string, keyPrefix
|
|
1947
|
+
* @param {Array<{ namespace: string, key?: string, keyPrefix?: string, updatedAt: number }>} [cleanups]
|
|
1812
1948
|
* @returns {Promise<{ upserted: number, skipped: number, outcomes: boolean[] }>}
|
|
1813
1949
|
* `outcomes[i]` 对应 entries[i] 是否真的写入(changes > 0)。
|
|
1814
1950
|
*/
|
|
@@ -1819,12 +1955,12 @@ var D1Adapter = class {
|
|
|
1819
1955
|
value = excluded.value,
|
|
1820
1956
|
updated_at = excluded.updated_at
|
|
1821
1957
|
WHERE excluded.updated_at >= client_state.updated_at`;
|
|
1822
|
-
const
|
|
1958
|
+
const CLEANUP_PREFIX_SQL = `DELETE FROM client_state
|
|
1823
1959
|
WHERE user_id = ? AND namespace = ? AND key LIKE ? ESCAPE '\\' AND updated_at <= ?`;
|
|
1960
|
+
const CLEANUP_KEY_SQL = `DELETE FROM client_state
|
|
1961
|
+
WHERE user_id = ? AND namespace = ? AND key = ? AND updated_at <= ?`;
|
|
1824
1962
|
const buildStatements = () => [
|
|
1825
|
-
...cleanups.map(
|
|
1826
|
-
(c) => this._db.prepare(CLEANUP_SQL).bind(userId, c.namespace, `${escapeLikePrefix(c.keyPrefix)}%`, c.updatedAt)
|
|
1827
|
-
),
|
|
1963
|
+
...cleanups.map((c) => typeof c.key === "string" ? this._db.prepare(CLEANUP_KEY_SQL).bind(userId, c.namespace, c.key, c.updatedAt) : this._db.prepare(CLEANUP_PREFIX_SQL).bind(userId, c.namespace, `${escapeLikePrefix(c.keyPrefix)}%`, c.updatedAt)),
|
|
1828
1964
|
...entries.map(
|
|
1829
1965
|
(entry) => this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
|
|
1830
1966
|
)
|
|
@@ -1987,10 +2123,7 @@ function createVapidPublicKeyHandler(ctx) {
|
|
|
1987
2123
|
}
|
|
1988
2124
|
|
|
1989
2125
|
// src/server/handlers/client-state.js
|
|
1990
|
-
var MAX_STATE_ENTRIES_PER_REQUEST =
|
|
1991
|
-
var MAX_NAMESPACE_CHARS = 128;
|
|
1992
|
-
var MAX_KEY_CHARS = 256;
|
|
1993
|
-
var utf82 = new TextEncoder();
|
|
2126
|
+
var MAX_STATE_ENTRIES_PER_REQUEST = MAX_STATE_ENTRIES_PER_BATCH;
|
|
1994
2127
|
function err(status, code, message, details) {
|
|
1995
2128
|
const error = details === void 0 ? { code, message } : { code, message, details };
|
|
1996
2129
|
return { status, body: { success: false, error } };
|
|
@@ -2028,7 +2161,7 @@ function validateEntry(entry, index, maxValueBytes) {
|
|
|
2028
2161
|
if (typeof entry.value !== "string") {
|
|
2029
2162
|
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`);
|
|
2030
2163
|
}
|
|
2031
|
-
const bytes =
|
|
2164
|
+
const bytes = stateValueBytes(entry.value);
|
|
2032
2165
|
if (bytes > maxValueBytes) {
|
|
2033
2166
|
return rejectEntry(entry, index, "STATE_VALUE_TOO_LARGE", `entries[${index}].value \u8D85\u8FC7\u5355\u6761\u603B\u4E0A\u9650`, { bytes, maxBytes: maxValueBytes });
|
|
2034
2167
|
}
|
|
@@ -2082,56 +2215,7 @@ function createClientStateHandler(ctx) {
|
|
|
2082
2215
|
if (typeof db.upsertClientState !== "function") {
|
|
2083
2216
|
return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
|
|
2084
2217
|
}
|
|
2085
|
-
const
|
|
2086
|
-
const cleanups = [];
|
|
2087
|
-
const rootRowIndexes = [];
|
|
2088
|
-
for (const entry of accepted) {
|
|
2089
|
-
cleanups.push({
|
|
2090
|
-
namespace: chunkNamespaceFor(entry.namespace),
|
|
2091
|
-
keyPrefix: chunkKeyPrefixFor(entry.key),
|
|
2092
|
-
updatedAt: entry.updatedAt
|
|
2093
|
-
});
|
|
2094
|
-
rootRowIndexes.push(physicalRows.length);
|
|
2095
|
-
if (utf82.encode(entry.value).length <= STATE_CHUNK_SLICE_BYTES) {
|
|
2096
|
-
physicalRows.push({
|
|
2097
|
-
namespace: entry.namespace,
|
|
2098
|
-
key: entry.key,
|
|
2099
|
-
value: await encryptForStorage(entry.value, userKey),
|
|
2100
|
-
updatedAt: entry.updatedAt
|
|
2101
|
-
});
|
|
2102
|
-
} else {
|
|
2103
|
-
const slices = splitStateValue(entry.value);
|
|
2104
|
-
physicalRows.push({
|
|
2105
|
-
namespace: entry.namespace,
|
|
2106
|
-
key: entry.key,
|
|
2107
|
-
value: buildChunkedRootValue(slices.length),
|
|
2108
|
-
updatedAt: entry.updatedAt
|
|
2109
|
-
});
|
|
2110
|
-
const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
|
|
2111
|
-
for (let c = 0; c < encryptedSlices.length; c++) {
|
|
2112
|
-
physicalRows.push({
|
|
2113
|
-
namespace: chunkNamespaceFor(entry.namespace),
|
|
2114
|
-
key: chunkKeyFor(entry.key, c),
|
|
2115
|
-
value: encryptedSlices[c],
|
|
2116
|
-
updatedAt: entry.updatedAt
|
|
2117
|
-
});
|
|
2118
|
-
}
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
let upserted = 0;
|
|
2122
|
-
let skipped = 0;
|
|
2123
|
-
if (physicalRows.length > 0) {
|
|
2124
|
-
const result = await db.upsertClientState(userId, physicalRows, cleanups);
|
|
2125
|
-
if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
|
|
2126
|
-
for (const rootIndex of rootRowIndexes) {
|
|
2127
|
-
if (result.outcomes[rootIndex]) upserted++;
|
|
2128
|
-
else skipped++;
|
|
2129
|
-
}
|
|
2130
|
-
} else {
|
|
2131
|
-
upserted = result.upserted;
|
|
2132
|
-
skipped = result.skipped;
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2218
|
+
const { upserted, skipped } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
|
|
2135
2219
|
const data = { upserted, skipped };
|
|
2136
2220
|
if (rejected.length > 0) data.rejected = rejected;
|
|
2137
2221
|
return { status: 200, body: { success: true, data } };
|
|
@@ -2178,7 +2262,7 @@ function createClientStateHandler(ctx) {
|
|
|
2178
2262
|
}
|
|
2179
2263
|
|
|
2180
2264
|
// src/server/lib/version.js
|
|
2181
|
-
var SERVER_VERSION = true ? "2.6.0-next.
|
|
2265
|
+
var SERVER_VERSION = true ? "2.6.0-next.7" : "0.0.0-dev";
|
|
2182
2266
|
|
|
2183
2267
|
// src/server/handlers/capabilities.js
|
|
2184
2268
|
var SERVER_FEATURES = Object.freeze([
|
|
@@ -2187,6 +2271,7 @@ var SERVER_FEATURES = Object.freeze([
|
|
|
2187
2271
|
"client-state-partial-failure",
|
|
2188
2272
|
"agentic-hooks",
|
|
2189
2273
|
"agentic-scratch",
|
|
2274
|
+
"agentic-write-state",
|
|
2190
2275
|
"vapid-public-key"
|
|
2191
2276
|
]);
|
|
2192
2277
|
function createCapabilitiesHandler(ctx) {
|
|
@@ -2254,6 +2339,19 @@ var NONCE_INFO = utf8("Content-Encoding: nonce\0");
|
|
|
2254
2339
|
var VAPID_DEFAULT_TTL = 60;
|
|
2255
2340
|
var VAPID_TOKEN_LIFETIME = 12 * 3600;
|
|
2256
2341
|
var RECORD_SIZE = 4096;
|
|
2342
|
+
var WEB_PUSH_MAX_BODY_BYTES = 4096;
|
|
2343
|
+
var WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES = 16 + 4 + 1 + 65 + 1 + 16;
|
|
2344
|
+
var MAX_PUSH_PAYLOAD_BYTES = WEB_PUSH_MAX_BODY_BYTES - WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES;
|
|
2345
|
+
var payloadEncoder = new TextEncoder();
|
|
2346
|
+
function measurePushPayload(payload) {
|
|
2347
|
+
const bytes = payloadEncoder.encode(typeof payload === "string" ? payload : String(payload)).length;
|
|
2348
|
+
return {
|
|
2349
|
+
bytes,
|
|
2350
|
+
maxBytes: MAX_PUSH_PAYLOAD_BYTES,
|
|
2351
|
+
remainingBytes: MAX_PUSH_PAYLOAD_BYTES - bytes,
|
|
2352
|
+
withinLimit: bytes <= MAX_PUSH_PAYLOAD_BYTES
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2257
2355
|
async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl }) {
|
|
2258
2356
|
if (!subscription || typeof subscription.endpoint !== "string") {
|
|
2259
2357
|
throw new Error("sendWebPush: invalid subscription");
|
|
@@ -2268,6 +2366,16 @@ async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl
|
|
|
2268
2366
|
if (typeof subscriptionKeys.p256dh !== "string" || typeof subscriptionKeys.auth !== "string") {
|
|
2269
2367
|
throw new Error("sendWebPush: subscription.keys.p256dh and .auth are required");
|
|
2270
2368
|
}
|
|
2369
|
+
const size = measurePushPayload(payload);
|
|
2370
|
+
if (!size.withinLimit) {
|
|
2371
|
+
const err2 = new Error(
|
|
2372
|
+
`sendWebPush: payload is ${size.bytes} bytes, over the ${MAX_PUSH_PAYLOAD_BYTES}-byte limit (push services cap the encrypted body at ${WEB_PUSH_MAX_BODY_BYTES} bytes; aes128gcm adds ${WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES} bytes)`
|
|
2373
|
+
);
|
|
2374
|
+
err2.code = "PUSH_PAYLOAD_TOO_LARGE";
|
|
2375
|
+
err2.bytes = size.bytes;
|
|
2376
|
+
err2.maxBytes = MAX_PUSH_PAYLOAD_BYTES;
|
|
2377
|
+
throw err2;
|
|
2378
|
+
}
|
|
2271
2379
|
const encryptedBody = await encryptPushPayload({
|
|
2272
2380
|
plaintext: utf8(payload),
|
|
2273
2381
|
uaPublicKey: base64UrlToBytes(subscriptionKeys.p256dh),
|
|
@@ -2542,6 +2650,8 @@ function createSingleUserCloudflareWorker(buildConfig) {
|
|
|
2542
2650
|
hooks: cfg.hooks || null,
|
|
2543
2651
|
maxToolIterations: cfg.maxToolIterations,
|
|
2544
2652
|
totalTimeoutMs: cfg.totalTimeoutMs,
|
|
2653
|
+
// hook 的 ctx.writeState() 用它判断单条 value 的上限,和 PUT /client-state 同一个配置。
|
|
2654
|
+
maxStateValueBytes: cfg.maxStateValueBytes,
|
|
2545
2655
|
// 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
|
|
2546
2656
|
claimLeaseMs: cfg.claimLeaseMs
|
|
2547
2657
|
});
|
|
@@ -2575,6 +2685,10 @@ export {
|
|
|
2575
2685
|
createMessagesHandler,
|
|
2576
2686
|
createD1Adapter,
|
|
2577
2687
|
createSingleUserServer,
|
|
2688
|
+
WEB_PUSH_MAX_BODY_BYTES,
|
|
2689
|
+
WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES,
|
|
2690
|
+
MAX_PUSH_PAYLOAD_BYTES,
|
|
2691
|
+
measurePushPayload,
|
|
2578
2692
|
createWebCryptoWebPush,
|
|
2579
2693
|
createSingleUserCloudflareWorker
|
|
2580
2694
|
};
|