@rei-standard/amsg-server 2.6.0-next.13 → 2.6.0-next.15
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/dist/adapters/d1.d.ts +106 -0
- package/dist/adapters/interface.d.ts +50 -0
- package/dist/adapters/neon.d.ts +10 -58
- package/dist/adapters/pg-shared.d.ts +114 -0
- package/dist/adapters/pg.d.ts +10 -58
- package/dist/adapters/schema.d.ts +1 -1
- package/dist/adapters/schema.sqlite.d.ts +3 -1
- package/dist/{chunk-B2FDV7YK.mjs → chunk-34I2YSWE.mjs} +10 -0
- package/dist/{chunk-TV7N3RTH.mjs → chunk-D3DQF647.mjs} +1086 -348
- package/dist/{chunk-V2SDWGUB.cjs → chunk-OUPM4HAV.cjs} +10 -0
- package/dist/{chunk-5IOYCCFD.cjs → chunk-TCKPLM3P.cjs} +1086 -348
- package/dist/chunk-TFFBMYA2.cjs +95 -0
- package/dist/chunk-UYROU4L3.mjs +95 -0
- package/dist/cloudflare/single-user-worker.d.ts +2 -0
- package/dist/cloudflare.cjs +3 -2
- package/dist/cloudflare.mjs +2 -1
- package/dist/handlers/outbox.d.ts +7 -0
- package/dist/index.cjs +64 -36
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.mjs +45 -17
- package/dist/lib/client-state-store.d.ts +18 -3
- package/dist/lib/errors.d.ts +50 -0
- package/dist/lib/message-processor.d.ts +8 -2
- package/dist/lib/outbox-store.d.ts +33 -0
- package/dist/lib/push-subscription-store.d.ts +10 -2
- package/dist/lib/request.d.ts +36 -0
- package/dist/lib/run-tick.d.ts +28 -0
- package/dist/lib/validation.d.ts +1 -7
- package/dist/{neon-CUIGAB2T.mjs → neon-XLWK27RL.mjs} +29 -104
- package/dist/{neon-3ASBLLCM.cjs → neon-ZWA2U7IZ.cjs} +37 -112
- package/dist/{pg-IEZTAR43.cjs → pg-Q4VU6D75.cjs} +35 -112
- package/dist/{pg-KMJPL6QV.mjs → pg-X63HFEV7.mjs} +27 -104
- package/dist/single-user.d.ts +4 -0
- package/package.json +2 -2
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UPDATABLE_COLUMNS
|
|
3
|
+
} from "./chunk-34I2YSWE.mjs";
|
|
4
|
+
|
|
1
5
|
// src/server/lib/recurrence.js
|
|
2
6
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
3
7
|
var MAX_LISTED_SKIPPED_OCCURRENCES = 32;
|
|
@@ -121,19 +125,11 @@ function nextFutureOccurrence(occurrenceMs, recurrenceType, nowMs, tzId) {
|
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
// src/server/lib/validation.js
|
|
124
|
-
import { validateAvatarUrl, validateLlmMessagesShape } from "@rei-standard/amsg-shared";
|
|
128
|
+
import { isValidUrl, validateAvatarUrl, validateLlmMessagesShape } from "@rei-standard/amsg-shared";
|
|
125
129
|
function isValidISO8601(dateString) {
|
|
126
130
|
const date = new Date(dateString);
|
|
127
131
|
return date instanceof Date && !isNaN(date.getTime());
|
|
128
132
|
}
|
|
129
|
-
function isValidUrl(urlString) {
|
|
130
|
-
try {
|
|
131
|
-
new URL(urlString);
|
|
132
|
-
return true;
|
|
133
|
-
} catch {
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
133
|
function isValidUUID(uuid) {
|
|
138
134
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
139
135
|
return uuidRegex.test(uuid);
|
|
@@ -168,9 +164,9 @@ function validateSplitPattern(value) {
|
|
|
168
164
|
return null;
|
|
169
165
|
}
|
|
170
166
|
function validateLlmMessagesArray(messages) {
|
|
171
|
-
const
|
|
172
|
-
if (!
|
|
173
|
-
const { code, index: i, toolCallIndex: j } =
|
|
167
|
+
const err4 = validateLlmMessagesShape(messages);
|
|
168
|
+
if (!err4) return null;
|
|
169
|
+
const { code, index: i, toolCallIndex: j } = err4;
|
|
174
170
|
switch (code) {
|
|
175
171
|
case "MESSAGES_NOT_ARRAY":
|
|
176
172
|
return "messages must be a non-empty array";
|
|
@@ -200,11 +196,32 @@ function validateScheduleMessagePayload(payload) {
|
|
|
200
196
|
if (!payload.messageType || !["fixed", "prompted", "auto", "instant"].includes(payload.messageType)) {
|
|
201
197
|
return { valid: false, errorCode: "INVALID_MESSAGE_TYPE", errorMessage: "\u6D88\u606F\u7C7B\u578B\u65E0\u6548", details: { providedType: payload.messageType, allowedTypes: ["fixed", "prompted", "auto", "instant"] } };
|
|
202
198
|
}
|
|
203
|
-
if (
|
|
204
|
-
return { valid: false, errorCode: "
|
|
199
|
+
if (payload.immediate !== void 0 && typeof payload.immediate !== "boolean") {
|
|
200
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "immediate \u5FC5\u987B\u662F\u5E03\u5C14\u503C", details: { invalidFields: ["immediate"] } };
|
|
205
201
|
}
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
const immediate = payload.immediate === true;
|
|
203
|
+
if (immediate && payload.messageType === "instant") {
|
|
204
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "messageType 'instant' \u672C\u5C31\u7ACB\u5373\u6295\u9012\uFF0C\u4E0D\u80FD\u518D\u5E26 immediate", details: { invalidFields: ["immediate"] } };
|
|
205
|
+
}
|
|
206
|
+
if (immediate) {
|
|
207
|
+
if (payload.firstSendTime !== void 0 && payload.firstSendTime !== null && !isValidISO8601(payload.firstSendTime)) {
|
|
208
|
+
return { valid: false, errorCode: "INVALID_TIMESTAMP", errorMessage: "\u65F6\u95F4\u683C\u5F0F\u65E0\u6548", details: { field: "firstSendTime" } };
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
if (!payload.firstSendTime || !isValidISO8601(payload.firstSendTime)) {
|
|
212
|
+
return { valid: false, errorCode: "INVALID_TIMESTAMP", errorMessage: "\u65F6\u95F4\u683C\u5F0F\u65E0\u6548", details: { field: "firstSendTime" } };
|
|
213
|
+
}
|
|
214
|
+
if (payload.firstSendTime && new Date(payload.firstSendTime) <= /* @__PURE__ */ new Date()) {
|
|
215
|
+
return { valid: false, errorCode: "INVALID_TIMESTAMP", errorMessage: "\u65F6\u95F4\u5FC5\u987B\u5728\u672A\u6765", details: { field: "firstSendTime", reason: "must be in the future" } };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (payload.supersedesUuid !== void 0 && payload.supersedesUuid !== null) {
|
|
219
|
+
if (typeof payload.supersedesUuid !== "string" || !isValidUUID(payload.supersedesUuid)) {
|
|
220
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "supersedesUuid \u5FC5\u987B\u662F\u5408\u6CD5 UUID", details: { invalidFields: ["supersedesUuid"] } };
|
|
221
|
+
}
|
|
222
|
+
if (payload.uuid && payload.supersedesUuid === payload.uuid) {
|
|
223
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "supersedesUuid \u4E0D\u80FD\u4E0E\u672C\u6761\u4EFB\u52A1\u7684 uuid \u76F8\u540C", details: { invalidFields: ["supersedesUuid"] } };
|
|
224
|
+
}
|
|
208
225
|
}
|
|
209
226
|
if (payload.pushSubscription !== void 0) {
|
|
210
227
|
return {
|
|
@@ -239,10 +256,10 @@ function validateScheduleMessagePayload(payload) {
|
|
|
239
256
|
};
|
|
240
257
|
}
|
|
241
258
|
if (hasMessages) {
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
259
|
+
const err4 = validateLlmMessagesArray(payload.messages);
|
|
260
|
+
if (err4) {
|
|
244
261
|
return {
|
|
245
|
-
error: { code: "INVALID_PARAMETERS", message:
|
|
262
|
+
error: { code: "INVALID_PARAMETERS", message: err4, details: { invalidFields: ["messages"] } },
|
|
246
263
|
hasCompletePrompt: false,
|
|
247
264
|
hasMessages: true
|
|
248
265
|
};
|
|
@@ -292,6 +309,9 @@ function validateScheduleMessagePayload(payload) {
|
|
|
292
309
|
if (splitErr) {
|
|
293
310
|
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: splitErr, details: { invalidFields: ["splitPattern"] } };
|
|
294
311
|
}
|
|
312
|
+
if (payload.llmExtraBody !== void 0 && payload.llmExtraBody !== null && (typeof payload.llmExtraBody !== "object" || Array.isArray(payload.llmExtraBody))) {
|
|
313
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "llmExtraBody \u5FC5\u987B\u662F\u666E\u901A\u5BF9\u8C61", details: { invalidFields: ["llmExtraBody"] } };
|
|
314
|
+
}
|
|
295
315
|
return { valid: true };
|
|
296
316
|
}
|
|
297
317
|
|
|
@@ -436,6 +456,16 @@ function parseEncryptedBody(body) {
|
|
|
436
456
|
}
|
|
437
457
|
return parsedBody;
|
|
438
458
|
}
|
|
459
|
+
function errorResponse(status, code, message, details) {
|
|
460
|
+
const error = details === void 0 ? { code, message } : { code, message, details };
|
|
461
|
+
return { status, body: { success: false, error } };
|
|
462
|
+
}
|
|
463
|
+
function requireUserId(headers) {
|
|
464
|
+
const userId = getHeader(headers, "x-user-id");
|
|
465
|
+
if (!userId) return { error: errorResponse(400, "USER_ID_REQUIRED", "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26") };
|
|
466
|
+
if (!isValidUUIDv4(userId)) return { error: errorResponse(400, "INVALID_USER_ID_FORMAT", "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F") };
|
|
467
|
+
return { userId };
|
|
468
|
+
}
|
|
439
469
|
function getHeader(headers = {}, name) {
|
|
440
470
|
if (!headers || typeof headers !== "object") {
|
|
441
471
|
return "";
|
|
@@ -459,19 +489,9 @@ function createGetUserKeyHandler(ctx) {
|
|
|
459
489
|
return tenantResult.error;
|
|
460
490
|
}
|
|
461
491
|
const { masterKey } = tenantResult.context;
|
|
462
|
-
const
|
|
463
|
-
if (
|
|
464
|
-
|
|
465
|
-
status: 400,
|
|
466
|
-
body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26" } }
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
if (!isValidUUIDv4(userId)) {
|
|
470
|
-
return {
|
|
471
|
-
status: 400,
|
|
472
|
-
body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } }
|
|
473
|
-
};
|
|
474
|
-
}
|
|
492
|
+
const gate = requireUserId(effectiveHeaders);
|
|
493
|
+
if (gate.error) return gate.error;
|
|
494
|
+
const { userId } = gate;
|
|
475
495
|
return {
|
|
476
496
|
status: 200,
|
|
477
497
|
body: {
|
|
@@ -486,6 +506,14 @@ function createGetUserKeyHandler(ctx) {
|
|
|
486
506
|
return { GET };
|
|
487
507
|
}
|
|
488
508
|
|
|
509
|
+
// src/server/lib/agentic-fire.js
|
|
510
|
+
import {
|
|
511
|
+
assertValidDecision,
|
|
512
|
+
buildSessionContext,
|
|
513
|
+
extractAssistantMessage,
|
|
514
|
+
extractToolCallsFromDecision
|
|
515
|
+
} from "@rei-standard/amsg-shared";
|
|
516
|
+
|
|
489
517
|
// src/server/lib/db-errors.js
|
|
490
518
|
function isUniqueViolation(error) {
|
|
491
519
|
if (!error || typeof error !== "object") return false;
|
|
@@ -495,46 +523,6 @@ function isUniqueViolation(error) {
|
|
|
495
523
|
return message.includes("duplicate key") || message.includes("unique constraint");
|
|
496
524
|
}
|
|
497
525
|
|
|
498
|
-
// src/server/lib/push-subscription-store.js
|
|
499
|
-
function supportsPushSubscriptionStore(db) {
|
|
500
|
-
return !!db && typeof db.getPushSubscription === "function" && typeof db.upsertPushSubscription === "function" && typeof db.deletePushSubscription === "function";
|
|
501
|
-
}
|
|
502
|
-
function isPushSubscriptionShape(subscription) {
|
|
503
|
-
return !!subscription && typeof subscription === "object" && !Array.isArray(subscription) && typeof /** @type {{ endpoint?: unknown }} */
|
|
504
|
-
subscription.endpoint === "string" && /** @type {{ endpoint: string }} */
|
|
505
|
-
subscription.endpoint.trim().length > 0;
|
|
506
|
-
}
|
|
507
|
-
async function savePushSubscription({ db, userId, userKey, subscription, updatedAt }) {
|
|
508
|
-
const at = Number.isInteger(updatedAt) && updatedAt > 0 ? updatedAt : Date.now();
|
|
509
|
-
const encrypted = await encryptForStorage(JSON.stringify(subscription), userKey);
|
|
510
|
-
await db.upsertPushSubscription(userId, encrypted, at);
|
|
511
|
-
return { updatedAt: at };
|
|
512
|
-
}
|
|
513
|
-
async function loadPushSubscription({ db, userId, userKey }) {
|
|
514
|
-
const row = await db.getPushSubscription(userId);
|
|
515
|
-
if (!row || typeof row.subscription !== "string" || !row.subscription) return null;
|
|
516
|
-
const subscription = JSON.parse(await decryptFromStorage(row.subscription, userKey));
|
|
517
|
-
return { subscription, updatedAt: row.updated_at ?? null };
|
|
518
|
-
}
|
|
519
|
-
async function resolvePushSubscription({ db, userId, userKey }) {
|
|
520
|
-
if (!supportsPushSubscriptionStore(db)) {
|
|
521
|
-
throw new Error("PUSH_SUBSCRIPTION_STORE_UNSUPPORTED: \u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u7528\u6237\u7EA7\u63A8\u9001\u8BA2\u9605\u5B58\u50A8");
|
|
522
|
-
}
|
|
523
|
-
const stored = await loadPushSubscription({ db, userId, userKey });
|
|
524
|
-
if (!stored) {
|
|
525
|
-
throw new Error("PUSH_SUBSCRIPTION_MISSING: \u8BE5\u7528\u6237\u8FD8\u6CA1\u6709\u767B\u8BB0\u63A8\u9001\u8BA2\u9605\uFF08PUT /push-subscription\uFF09");
|
|
526
|
-
}
|
|
527
|
-
return stored.subscription;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// src/server/lib/message-processor.js
|
|
531
|
-
import {
|
|
532
|
-
buildContentPush,
|
|
533
|
-
buildReasoningPush,
|
|
534
|
-
readReasoningContent,
|
|
535
|
-
stripReasoningTags
|
|
536
|
-
} from "@rei-standard/amsg-shared";
|
|
537
|
-
|
|
538
526
|
// src/server/lib/llm.js
|
|
539
527
|
import {
|
|
540
528
|
callLlm,
|
|
@@ -542,14 +530,6 @@ import {
|
|
|
542
530
|
normalizeAiApiUrl
|
|
543
531
|
} from "@rei-standard/amsg-shared";
|
|
544
532
|
|
|
545
|
-
// src/server/lib/agentic-fire.js
|
|
546
|
-
import {
|
|
547
|
-
assertValidDecision,
|
|
548
|
-
buildSessionContext,
|
|
549
|
-
extractAssistantMessage,
|
|
550
|
-
extractToolCallsFromDecision
|
|
551
|
-
} from "@rei-standard/amsg-shared";
|
|
552
|
-
|
|
553
533
|
// src/server/lib/state-chunks.js
|
|
554
534
|
import { chunkReasoningByUtf8Bytes } from "@rei-standard/amsg-shared";
|
|
555
535
|
var STATE_CHUNK_SLICE_BYTES = 200 * 1024;
|
|
@@ -635,29 +615,32 @@ async function writeClientStateEntries({ db, userId, userKey, entries }) {
|
|
|
635
615
|
const physicalRows = [];
|
|
636
616
|
const cleanups = [];
|
|
637
617
|
const rootRowIndexes = [];
|
|
618
|
+
const rootRowEntries = [];
|
|
638
619
|
let deleted = 0;
|
|
639
620
|
for (const entry of entries) {
|
|
621
|
+
const guardAt = Number.isInteger(entry.version) && entry.version > 0 ? entry.version : entry.updatedAt;
|
|
640
622
|
cleanups.push({
|
|
641
623
|
namespace: chunkNamespaceFor(entry.namespace),
|
|
642
624
|
keyPrefix: chunkKeyPrefixFor(entry.key),
|
|
643
|
-
updatedAt:
|
|
625
|
+
updatedAt: guardAt
|
|
644
626
|
});
|
|
645
627
|
if (entry.value === null) {
|
|
646
628
|
cleanups.push({
|
|
647
629
|
namespace: entry.namespace,
|
|
648
630
|
key: entry.key,
|
|
649
|
-
updatedAt:
|
|
631
|
+
updatedAt: guardAt
|
|
650
632
|
});
|
|
651
633
|
deleted++;
|
|
652
634
|
continue;
|
|
653
635
|
}
|
|
654
636
|
rootRowIndexes.push(physicalRows.length);
|
|
637
|
+
rootRowEntries.push(entry);
|
|
655
638
|
if (stateValueBytes(entry.value) <= STATE_CHUNK_SLICE_BYTES) {
|
|
656
639
|
physicalRows.push({
|
|
657
640
|
namespace: entry.namespace,
|
|
658
641
|
key: entry.key,
|
|
659
642
|
value: await encryptForStorage(entry.value, userKey),
|
|
660
|
-
updatedAt:
|
|
643
|
+
updatedAt: guardAt
|
|
661
644
|
});
|
|
662
645
|
} else {
|
|
663
646
|
const slices = splitStateValue(entry.value);
|
|
@@ -665,7 +648,7 @@ async function writeClientStateEntries({ db, userId, userKey, entries }) {
|
|
|
665
648
|
namespace: entry.namespace,
|
|
666
649
|
key: entry.key,
|
|
667
650
|
value: buildChunkedRootValue(slices.length),
|
|
668
|
-
updatedAt:
|
|
651
|
+
updatedAt: guardAt
|
|
669
652
|
});
|
|
670
653
|
const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
|
|
671
654
|
for (let c = 0; c < encryptedSlices.length; c++) {
|
|
@@ -673,27 +656,33 @@ async function writeClientStateEntries({ db, userId, userKey, entries }) {
|
|
|
673
656
|
namespace: chunkNamespaceFor(entry.namespace),
|
|
674
657
|
key: chunkKeyFor(entry.key, c),
|
|
675
658
|
value: encryptedSlices[c],
|
|
676
|
-
updatedAt:
|
|
659
|
+
updatedAt: guardAt
|
|
677
660
|
});
|
|
678
661
|
}
|
|
679
662
|
}
|
|
680
663
|
}
|
|
681
664
|
if (physicalRows.length === 0 && cleanups.length === 0) {
|
|
682
|
-
return { upserted: 0, skipped: 0, deleted: 0 };
|
|
665
|
+
return { upserted: 0, skipped: 0, deleted: 0, skippedEntries: [] };
|
|
683
666
|
}
|
|
684
667
|
const result = await db.upsertClientState(userId, physicalRows, cleanups);
|
|
685
668
|
let upserted = 0;
|
|
686
669
|
let skipped = 0;
|
|
670
|
+
const skippedEntries = [];
|
|
687
671
|
if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
|
|
688
|
-
for (
|
|
689
|
-
if (result.outcomes[
|
|
690
|
-
|
|
672
|
+
for (let i = 0; i < rootRowIndexes.length; i++) {
|
|
673
|
+
if (result.outcomes[rootRowIndexes[i]]) {
|
|
674
|
+
upserted++;
|
|
675
|
+
} else {
|
|
676
|
+
skipped++;
|
|
677
|
+
const entry = rootRowEntries[i];
|
|
678
|
+
skippedEntries.push({ namespace: entry.namespace, key: entry.key });
|
|
679
|
+
}
|
|
691
680
|
}
|
|
692
681
|
} else {
|
|
693
682
|
upserted = result.upserted;
|
|
694
683
|
skipped = result.skipped;
|
|
695
684
|
}
|
|
696
|
-
return { upserted, skipped, deleted };
|
|
685
|
+
return { upserted, skipped, deleted, skippedEntries };
|
|
697
686
|
}
|
|
698
687
|
|
|
699
688
|
// src/server/lib/state-accessors.js
|
|
@@ -754,11 +743,15 @@ function createStateAccessors({ db, userId, userKey, maxStateValueBytes, now })
|
|
|
754
743
|
if (entry.updatedAt !== void 0 && (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0)) {
|
|
755
744
|
throw new TypeError(`writeState: entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
|
|
756
745
|
}
|
|
746
|
+
if (entry.version !== void 0 && (!Number.isInteger(entry.version) || entry.version <= 0)) {
|
|
747
|
+
throw new TypeError(`writeState: entries[${index}].version \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08\u6BEB\u79D2\u65F6\u95F4\u6233\u6216\u5355\u8C03\u9012\u589E\u7248\u672C\u53F7\uFF09`);
|
|
748
|
+
}
|
|
757
749
|
return {
|
|
758
750
|
namespace,
|
|
759
751
|
key: entry.key,
|
|
760
752
|
value: entry.value,
|
|
761
|
-
updatedAt: entry.updatedAt ?? at
|
|
753
|
+
updatedAt: entry.updatedAt ?? at,
|
|
754
|
+
...entry.version !== void 0 ? { version: entry.version } : {}
|
|
762
755
|
};
|
|
763
756
|
});
|
|
764
757
|
return writeClientStateEntries({ db, userId, userKey, entries: normalized });
|
|
@@ -793,10 +786,92 @@ function projectTask(row, decryptedPayload, options = {}) {
|
|
|
793
786
|
...options.includeMetadata ? { metadata: payload.metadata ?? null } : {},
|
|
794
787
|
// 上一次没发出去的原因(run-tick 记进 payload 的 lastError)。
|
|
795
788
|
// reason 'stale' 表示错过触发时刻太久被判定不再补发;其余是投递失败的
|
|
796
|
-
//
|
|
797
|
-
|
|
789
|
+
// 错误信息。payload 里没有时退回行上的 last_error 列(同形状的脱敏摘要,
|
|
790
|
+
// 等重试期间的失败也记在那里)。没有记录 → null。
|
|
791
|
+
lastError: payload.lastError ?? parseRowLastError(row.last_error)
|
|
798
792
|
};
|
|
799
793
|
}
|
|
794
|
+
function parseRowLastError(raw) {
|
|
795
|
+
if (typeof raw !== "string" || !raw) return null;
|
|
796
|
+
try {
|
|
797
|
+
return JSON.parse(raw);
|
|
798
|
+
} catch (_error) {
|
|
799
|
+
return { reason: raw };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// src/server/lib/push-subscription-store.js
|
|
804
|
+
function supportsPushSubscriptionStore(db) {
|
|
805
|
+
return !!db && typeof db.getPushSubscription === "function" && typeof db.upsertPushSubscription === "function" && typeof db.deletePushSubscription === "function";
|
|
806
|
+
}
|
|
807
|
+
function isPushSubscriptionShape(subscription) {
|
|
808
|
+
return !!subscription && typeof subscription === "object" && !Array.isArray(subscription) && typeof /** @type {{ endpoint?: unknown }} */
|
|
809
|
+
subscription.endpoint === "string" && /** @type {{ endpoint: string }} */
|
|
810
|
+
subscription.endpoint.trim().length > 0;
|
|
811
|
+
}
|
|
812
|
+
async function savePushSubscription({ db, userId, userKey, subscription, updatedAt }) {
|
|
813
|
+
const at = Number.isInteger(updatedAt) && updatedAt > 0 ? updatedAt : Date.now();
|
|
814
|
+
const encrypted = await encryptForStorage(JSON.stringify(subscription), userKey);
|
|
815
|
+
await db.upsertPushSubscription(userId, encrypted, at);
|
|
816
|
+
return { updatedAt: at };
|
|
817
|
+
}
|
|
818
|
+
async function loadPushSubscription({ db, userId, userKey }) {
|
|
819
|
+
const row = await db.getPushSubscription(userId);
|
|
820
|
+
if (!row || typeof row.subscription !== "string" || !row.subscription) return null;
|
|
821
|
+
const subscription = JSON.parse(await decryptFromStorage(row.subscription, userKey));
|
|
822
|
+
return { subscription, updatedAt: row.updated_at ?? null };
|
|
823
|
+
}
|
|
824
|
+
function codedError(code, message) {
|
|
825
|
+
const error = new Error(`${code}: ${message}`);
|
|
826
|
+
error.code = code;
|
|
827
|
+
return error;
|
|
828
|
+
}
|
|
829
|
+
async function resolvePushSubscription({ db, userId, userKey, legacyFallback = null }) {
|
|
830
|
+
const fallback = isPushSubscriptionShape(legacyFallback) ? legacyFallback : null;
|
|
831
|
+
if (!supportsPushSubscriptionStore(db)) {
|
|
832
|
+
if (fallback) return fallback;
|
|
833
|
+
throw codedError("PUSH_SUBSCRIPTION_STORE_UNSUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u7528\u6237\u7EA7\u63A8\u9001\u8BA2\u9605\u5B58\u50A8");
|
|
834
|
+
}
|
|
835
|
+
const stored = await loadPushSubscription({ db, userId, userKey });
|
|
836
|
+
if (!stored) {
|
|
837
|
+
if (fallback) return fallback;
|
|
838
|
+
throw codedError("PUSH_SUBSCRIPTION_MISSING", "\u8BE5\u7528\u6237\u8FD8\u6CA1\u6709\u767B\u8BB0\u63A8\u9001\u8BA2\u9605\uFF08PUT /push-subscription\uFF09");
|
|
839
|
+
}
|
|
840
|
+
return stored.subscription;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// src/server/lib/outbox-store.js
|
|
844
|
+
function supportsOutbox(db) {
|
|
845
|
+
return !!db && typeof db.appendOutboxMessages === "function" && typeof db.markOutboxDelivered === "function";
|
|
846
|
+
}
|
|
847
|
+
async function appendPushesToOutbox({ db, userId, userKey, pushes }) {
|
|
848
|
+
if (!supportsOutbox(db) || !pushes || pushes.length === 0) return false;
|
|
849
|
+
try {
|
|
850
|
+
const now = Date.now();
|
|
851
|
+
const rows = await Promise.all(pushes.map(async (push) => ({
|
|
852
|
+
message_id: push.messageId,
|
|
853
|
+
task_uuid: push.taskUuid ?? null,
|
|
854
|
+
session_id: push.sessionId ?? null,
|
|
855
|
+
message_index: push.messageIndex ?? null,
|
|
856
|
+
total_messages: push.totalMessages ?? null,
|
|
857
|
+
payload: await encryptForStorage(JSON.stringify(push), userKey),
|
|
858
|
+
created_at: now
|
|
859
|
+
})));
|
|
860
|
+
await db.appendOutboxMessages(userId, rows);
|
|
861
|
+
return true;
|
|
862
|
+
} catch (error) {
|
|
863
|
+
console.warn("[amsg-server] outbox \u843D\u884C\u5931\u8D25\uFF08\u4E0D\u5F71\u54CD\u6295\u9012\uFF09:", error && error.message);
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async function markPushesDelivered({ db, userId, messageIds }) {
|
|
868
|
+
if (!supportsOutbox(db) || !messageIds || messageIds.length === 0) return;
|
|
869
|
+
try {
|
|
870
|
+
await db.markOutboxDelivered(userId, messageIds, Date.now());
|
|
871
|
+
} catch (error) {
|
|
872
|
+
console.warn("[amsg-server] outbox \u6807\u8BB0 delivered \u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", error && error.message);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
800
875
|
|
|
801
876
|
// src/server/lib/agentic-fire.js
|
|
802
877
|
var DEFAULT_MAX_TOOL_ITERATIONS = 5;
|
|
@@ -985,6 +1060,9 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
|
|
|
985
1060
|
maxTokens: decryptedPayload.maxTokens ?? null,
|
|
986
1061
|
temperature: decryptedPayload.temperature ?? null,
|
|
987
1062
|
splitPattern: decryptedPayload.splitPattern ?? null,
|
|
1063
|
+
// 与凭据同列的投递配置:中转的非标准参数(thinking 之类)跟着任务走,
|
|
1064
|
+
// 自排的后续任务不该突然掉档。
|
|
1065
|
+
llmExtraBody: decryptedPayload.llmExtraBody ?? null,
|
|
988
1066
|
metadata
|
|
989
1067
|
};
|
|
990
1068
|
const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey);
|
|
@@ -1018,6 +1096,57 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
|
|
|
1018
1096
|
nextSendAt: created.next_send_at ?? nextSendAt
|
|
1019
1097
|
};
|
|
1020
1098
|
};
|
|
1099
|
+
const cancelTask = async (uuid) => {
|
|
1100
|
+
if (typeof uuid !== "string" || !uuid.trim()) {
|
|
1101
|
+
throw new TypeError("cancelTask(uuid) \u9700\u8981\u975E\u7A7A\u5B57\u7B26\u4E32 uuid");
|
|
1102
|
+
}
|
|
1103
|
+
if (task.uuid != null && uuid === task.uuid) {
|
|
1104
|
+
throw new RangeError(
|
|
1105
|
+
"cancelTask: \u4E0D\u80FD\u53D6\u6D88\u5F53\u524D\u6B63\u5728 fire \u7684\u4EFB\u52A1\u2014\u2014\u8FD9\u6761\u7684\u6536\u5C3E\u5F52 run-tick \u7BA1\u3002\u8FD9\u6B21\u4E0D\u60F3\u53D1\u7684\u8BDD\uFF0C\u7528 onBeforeFire \u7684 { skip: true } \u6216 onLLMOutput \u7684 'skip-push'\u3002"
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
if (!ctx.db || typeof ctx.db.deleteTaskByUuid !== "function") {
|
|
1109
|
+
throw new Error("AGENTIC_CANCEL_UNSUPPORTED: \u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u5220\u4EFB\u52A1\uFF08\u7F3A deleteTaskByUuid\uFF09");
|
|
1110
|
+
}
|
|
1111
|
+
const cancelled = await ctx.db.deleteTaskByUuid(uuid, task.user_id);
|
|
1112
|
+
return { cancelled: !!cancelled };
|
|
1113
|
+
};
|
|
1114
|
+
const renewTask = async (uuid, nextSendAt) => {
|
|
1115
|
+
if (typeof uuid !== "string" || !uuid.trim()) {
|
|
1116
|
+
throw new TypeError("renewTask(uuid, nextSendAt) \u9700\u8981\u975E\u7A7A\u5B57\u7B26\u4E32 uuid");
|
|
1117
|
+
}
|
|
1118
|
+
if (task.uuid != null && uuid === task.uuid) {
|
|
1119
|
+
throw new RangeError("renewTask: \u4E0D\u80FD\u6539\u5F53\u524D\u6B63\u5728 fire \u7684\u4EFB\u52A1\u2014\u2014\u8FD9\u6761\u7684\u6392\u671F\u63A8\u8FDB\u5F52 run-tick \u7BA1");
|
|
1120
|
+
}
|
|
1121
|
+
if (typeof nextSendAt !== "string" || !nextSendAt.trim()) {
|
|
1122
|
+
throw new RangeError("renewTask: nextSendAt \u5FC5\u586B\uFF0C\u4E14\u5FC5\u987B\u662F ISO 8601 \u5B57\u7B26\u4E32");
|
|
1123
|
+
}
|
|
1124
|
+
const sendAt = new Date(nextSendAt);
|
|
1125
|
+
if (Number.isNaN(sendAt.getTime())) {
|
|
1126
|
+
throw new RangeError(`renewTask: nextSendAt \u89E3\u6790\u4E0D\u51FA\u5408\u6CD5\u65F6\u95F4\uFF1A${nextSendAt}`);
|
|
1127
|
+
}
|
|
1128
|
+
const earliest = nowFn() + MIN_SCHEDULE_LEAD_MS;
|
|
1129
|
+
if (sendAt.getTime() < earliest) {
|
|
1130
|
+
throw new RangeError(
|
|
1131
|
+
`renewTask: nextSendAt \u81F3\u5C11\u8981\u6BD4\u73B0\u5728\u665A ${MIN_SCHEDULE_LEAD_MS / 1e3} \u79D2\uFF08\u6700\u65E9\u53EF\u6392 ${new Date(earliest).toISOString()}\uFF0C\u6536\u5230 ${sendAt.toISOString()}\uFF09`
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
if (!ctx.db || typeof ctx.db.getTaskByUuid !== "function" || typeof ctx.db.updateTaskByUuid !== "function") {
|
|
1135
|
+
throw new Error("AGENTIC_RENEW_UNSUPPORTED: \u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u6539\u4EFB\u52A1\uFF08\u7F3A getTaskByUuid / updateTaskByUuid\uFF09");
|
|
1136
|
+
}
|
|
1137
|
+
const row = await ctx.db.getTaskByUuid(uuid, task.user_id);
|
|
1138
|
+
if (!row) return { renewed: false, reason: "not_found" };
|
|
1139
|
+
const payload = JSON.parse(await decryptFromStorage(row.encrypted_payload, userKey));
|
|
1140
|
+
const nextSendAtIso = sendAt.toISOString();
|
|
1141
|
+
const encrypted = await encryptForStorage(JSON.stringify({ ...payload, firstSendTime: nextSendAtIso }), userKey);
|
|
1142
|
+
const updated = await ctx.db.updateTaskByUuid(uuid, task.user_id, encrypted, {
|
|
1143
|
+
next_send_at: nextSendAtIso,
|
|
1144
|
+
retry_count: 0,
|
|
1145
|
+
...typeof ctx.db.claimTask === "function" ? { retry_after: null } : {}
|
|
1146
|
+
});
|
|
1147
|
+
if (!updated) return { renewed: false, reason: "not_found" };
|
|
1148
|
+
return { renewed: true, uuid, nextSendAt: nextSendAtIso };
|
|
1149
|
+
};
|
|
1021
1150
|
const scratch = {};
|
|
1022
1151
|
const fireCtx = Object.freeze({
|
|
1023
1152
|
task: buildHookTask(task, decryptedPayload),
|
|
@@ -1025,10 +1154,12 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
|
|
|
1025
1154
|
readState,
|
|
1026
1155
|
writeState,
|
|
1027
1156
|
scheduleTask,
|
|
1157
|
+
cancelTask,
|
|
1158
|
+
renewTask,
|
|
1028
1159
|
now: new Date(nowFn()),
|
|
1029
1160
|
scratch
|
|
1030
1161
|
});
|
|
1031
|
-
const progress = { sentCount: 0, total: 0, iterations: 0, skipReason: null };
|
|
1162
|
+
const progress = { sentCount: 0, total: 0, iterations: 0, skipReason: null, usage: null };
|
|
1032
1163
|
let settledStatus = "failed";
|
|
1033
1164
|
let settledError = null;
|
|
1034
1165
|
try {
|
|
@@ -1044,6 +1175,8 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
|
|
|
1044
1175
|
writeState,
|
|
1045
1176
|
scratch,
|
|
1046
1177
|
scheduleTask,
|
|
1178
|
+
cancelTask,
|
|
1179
|
+
renewTask,
|
|
1047
1180
|
fireCtx,
|
|
1048
1181
|
progress
|
|
1049
1182
|
});
|
|
@@ -1061,6 +1194,14 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
|
|
|
1061
1194
|
total: progress.total,
|
|
1062
1195
|
iterations: progress.iterations,
|
|
1063
1196
|
error: settledError,
|
|
1197
|
+
// 解密 payload 里的 metadata 子字段(与 onStaleSkip 同待遇):task 行本
|
|
1198
|
+
// 身是密文,宿主要靠它对上「这是哪个角色的哪类任务」——尤其是 fire 在
|
|
1199
|
+
// onBeforeFire 里就失败、宿主还没来得及自己记账的那种结局。凭据字段
|
|
1200
|
+
// (apiKey / pushSubscription)照旧不透传。
|
|
1201
|
+
metadata: decryptedPayload.metadata ?? null,
|
|
1202
|
+
// 最后一轮 LLM 响应的 usage(prompt/completion tokens;没跑到 LLM →
|
|
1203
|
+
// null)。宿主拿它更新用量记账,不用从 llmResponse 里自己扒。
|
|
1204
|
+
usage: progress.usage,
|
|
1064
1205
|
scratch,
|
|
1065
1206
|
readState,
|
|
1066
1207
|
writeState
|
|
@@ -1079,6 +1220,8 @@ async function runFireChain({
|
|
|
1079
1220
|
writeState,
|
|
1080
1221
|
scratch,
|
|
1081
1222
|
scheduleTask,
|
|
1223
|
+
cancelTask,
|
|
1224
|
+
renewTask,
|
|
1082
1225
|
fireCtx,
|
|
1083
1226
|
progress
|
|
1084
1227
|
}) {
|
|
@@ -1117,6 +1260,7 @@ async function runFireChain({
|
|
|
1117
1260
|
);
|
|
1118
1261
|
const assistantMessage = extractAssistantMessage(llmResponse);
|
|
1119
1262
|
messages = [...messages, assistantMessage];
|
|
1263
|
+
progress.usage = llmResponse && typeof llmResponse === "object" && llmResponse.usage || null;
|
|
1120
1264
|
const sessionCtx = Object.freeze({
|
|
1121
1265
|
...buildSessionContext({
|
|
1122
1266
|
sessionId,
|
|
@@ -1134,7 +1278,9 @@ async function runFireChain({
|
|
|
1134
1278
|
occurrenceMs,
|
|
1135
1279
|
readState,
|
|
1136
1280
|
writeState,
|
|
1137
|
-
scheduleTask
|
|
1281
|
+
scheduleTask,
|
|
1282
|
+
cancelTask,
|
|
1283
|
+
renewTask
|
|
1138
1284
|
});
|
|
1139
1285
|
const decision = await hooks.onLLMOutput(sessionCtx);
|
|
1140
1286
|
assertValidDecision(decision, { inlineToolCalls: true });
|
|
@@ -1233,7 +1379,8 @@ async function sendHookPushPayloads({
|
|
|
1233
1379
|
const total = pushPayloads.length;
|
|
1234
1380
|
let sentCount = 0;
|
|
1235
1381
|
progress.total = total;
|
|
1236
|
-
const afterSendBase = { task, total, scratch, readState, writeState };
|
|
1382
|
+
const afterSendBase = { task, total, scratch, readState, writeState, usage: progress.usage };
|
|
1383
|
+
const sentIds = [];
|
|
1237
1384
|
try {
|
|
1238
1385
|
if (!ctx.vapid || !ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
1239
1386
|
throw new Error("VAPID configuration missing - push notifications cannot be sent");
|
|
@@ -1241,9 +1388,11 @@ async function sendHookPushPayloads({
|
|
|
1241
1388
|
const pushSubscription = await resolvePushSubscription({
|
|
1242
1389
|
db: ctx.db,
|
|
1243
1390
|
userId: task.user_id,
|
|
1244
|
-
userKey
|
|
1391
|
+
userKey,
|
|
1392
|
+
legacyFallback: (decryptedPayload && decryptedPayload.pushSubscription) ?? null
|
|
1245
1393
|
});
|
|
1246
1394
|
const messageIdBase = task.id != null ? `msg_task_${task.id}${occurrenceSuffix(task)}` : `msg_${randomUUID()}`;
|
|
1395
|
+
const finalized = [];
|
|
1247
1396
|
for (let i = 0; i < total; i++) {
|
|
1248
1397
|
const push = { ...pushPayloads[i] };
|
|
1249
1398
|
if (typeof push.messageId !== "string" || !push.messageId) push.messageId = `${messageIdBase}_hook_${i}`;
|
|
@@ -1252,20 +1401,59 @@ async function sendHookPushPayloads({
|
|
|
1252
1401
|
push.messageIndex = i + 1;
|
|
1253
1402
|
push.totalMessages = total;
|
|
1254
1403
|
stampTaskIdentity(push, task, decryptedPayload, occurrenceMs);
|
|
1255
|
-
|
|
1404
|
+
finalized.push(push);
|
|
1405
|
+
}
|
|
1406
|
+
await appendPushesToOutbox({ db: ctx.db, userId: task.user_id, userKey, pushes: finalized });
|
|
1407
|
+
for (let i = 0; i < total; i++) {
|
|
1408
|
+
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(finalized[i]));
|
|
1256
1409
|
sentCount++;
|
|
1257
1410
|
progress.sentCount = sentCount;
|
|
1411
|
+
sentIds.push(finalized[i].messageId);
|
|
1258
1412
|
if (i < total - 1) await sleep(SLEEP_BETWEEN_MESSAGES_MS);
|
|
1259
1413
|
}
|
|
1260
1414
|
} catch (error) {
|
|
1415
|
+
await markPushesDelivered({ db: ctx.db, userId: task.user_id, messageIds: sentIds });
|
|
1261
1416
|
await notifyAfterSend(ctx, { ...afterSendBase, sentCount, error });
|
|
1262
1417
|
throw error;
|
|
1263
1418
|
}
|
|
1419
|
+
await markPushesDelivered({ db: ctx.db, userId: task.user_id, messageIds: sentIds });
|
|
1264
1420
|
await notifyAfterSend(ctx, { ...afterSendBase, sentCount, error: null });
|
|
1265
1421
|
return total;
|
|
1266
1422
|
}
|
|
1267
1423
|
|
|
1424
|
+
// src/server/lib/errors.js
|
|
1425
|
+
var NonRetryableError = class extends Error {
|
|
1426
|
+
/**
|
|
1427
|
+
* @param {string} message
|
|
1428
|
+
* @param {{ code?: string, cause?: unknown }} [options]
|
|
1429
|
+
*/
|
|
1430
|
+
constructor(message, options = {}) {
|
|
1431
|
+
super(message, options.cause !== void 0 ? { cause: options.cause } : void 0);
|
|
1432
|
+
this.name = "NonRetryableError";
|
|
1433
|
+
this.permanent = true;
|
|
1434
|
+
if (options.code) this.code = options.code;
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
function isNonRetryableError(error) {
|
|
1438
|
+
return !!error && typeof error === "object" && /** @type {any} */
|
|
1439
|
+
error.permanent === true;
|
|
1440
|
+
}
|
|
1441
|
+
function sanitizeErrorSummary(reason) {
|
|
1442
|
+
let s = String(reason ?? "").replace(/\s+/g, " ").trim();
|
|
1443
|
+
s = s.replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]");
|
|
1444
|
+
s = s.replace(/\b[A-Za-z]{2,6}-[A-Za-z0-9_-]{16,}/g, "[redacted]");
|
|
1445
|
+
s = s.replace(/[A-Za-z0-9+/_.-]{48,}/g, "[redacted]");
|
|
1446
|
+
if (s.length > 500) s = `${s.slice(0, 497)}\u2026`;
|
|
1447
|
+
return s;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1268
1450
|
// src/server/lib/message-processor.js
|
|
1451
|
+
import {
|
|
1452
|
+
buildContentPush,
|
|
1453
|
+
buildReasoningPush,
|
|
1454
|
+
readReasoningContent,
|
|
1455
|
+
stripReasoningTags
|
|
1456
|
+
} from "@rei-standard/amsg-shared";
|
|
1269
1457
|
var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
|
|
1270
1458
|
var SLEEP_BETWEEN_MESSAGES_MS2 = 1500;
|
|
1271
1459
|
function splitOnceByRegex(chunk, regex) {
|
|
@@ -1287,14 +1475,14 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
|
|
|
1287
1475
|
}
|
|
1288
1476
|
return chunks.length > 0 ? chunks : [messageContent];
|
|
1289
1477
|
}
|
|
1290
|
-
async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
1478
|
+
async function processSingleMessage(task, ctx, providedMasterKey, predecrypted = null) {
|
|
1291
1479
|
try {
|
|
1292
1480
|
const masterKey = providedMasterKey || ctx.masterKey;
|
|
1293
1481
|
if (!masterKey) {
|
|
1294
1482
|
return { success: false, messagesSent: 0, error: "TENANT_MASTER_KEY_MISSING" };
|
|
1295
1483
|
}
|
|
1296
|
-
const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
|
|
1297
|
-
const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
|
|
1484
|
+
const userKey = predecrypted && predecrypted.userKey || await deriveUserEncryptionKey(task.user_id, masterKey);
|
|
1485
|
+
const decryptedPayload = predecrypted && predecrypted.payload || JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
|
|
1298
1486
|
if (ctx.hooks && typeof ctx.hooks.onBeforeFire === "function" && taskNeedsLlm(decryptedPayload)) {
|
|
1299
1487
|
const agentic = await runAgenticFire({ task, decryptedPayload, userKey, ctx });
|
|
1300
1488
|
if (agentic.handled) return agentic.result;
|
|
@@ -1329,7 +1517,12 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
1329
1517
|
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
1330
1518
|
throw new Error("VAPID configuration missing - push notifications cannot be sent");
|
|
1331
1519
|
}
|
|
1332
|
-
const pushSubscription = await resolvePushSubscription({
|
|
1520
|
+
const pushSubscription = await resolvePushSubscription({
|
|
1521
|
+
db: ctx.db,
|
|
1522
|
+
userId: task.user_id,
|
|
1523
|
+
userKey,
|
|
1524
|
+
legacyFallback: decryptedPayload.pushSubscription ?? null
|
|
1525
|
+
});
|
|
1333
1526
|
const sessionId = task.id != null ? `sess_task_${task.id}${occurrenceSuffix(task)}` : `sess_${randomUUID()}`;
|
|
1334
1527
|
const source = decryptedPayload.messageType === "instant" ? "instant" : "scheduled";
|
|
1335
1528
|
const messageSubtype = decryptedPayload.messageSubtype || "chat";
|
|
@@ -1337,6 +1530,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
1337
1530
|
const metadata = decryptedPayload.metadata || {};
|
|
1338
1531
|
const messageIdBase = task.id != null ? `msg_task_${task.id}${occurrenceSuffix(task)}` : `msg_${randomUUID()}_instant`;
|
|
1339
1532
|
const occurrenceMs = occurrenceMsOf(task);
|
|
1533
|
+
const pushesToSend = [];
|
|
1340
1534
|
if (reasoning) {
|
|
1341
1535
|
const reasoningPush = buildReasoningPush({
|
|
1342
1536
|
messageType: decryptedPayload.messageType,
|
|
@@ -1352,8 +1546,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
1352
1546
|
metadata
|
|
1353
1547
|
});
|
|
1354
1548
|
stampTaskIdentity(reasoningPush, task, decryptedPayload, occurrenceMs);
|
|
1355
|
-
|
|
1356
|
-
await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
|
|
1549
|
+
pushesToSend.push(reasoningPush);
|
|
1357
1550
|
}
|
|
1358
1551
|
for (let i = 0; i < messages.length; i++) {
|
|
1359
1552
|
const contentPush = buildContentPush({
|
|
@@ -1372,14 +1565,30 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
1372
1565
|
metadata
|
|
1373
1566
|
});
|
|
1374
1567
|
stampTaskIdentity(contentPush, task, decryptedPayload, occurrenceMs);
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1568
|
+
pushesToSend.push(contentPush);
|
|
1569
|
+
}
|
|
1570
|
+
await appendPushesToOutbox({ db: ctx.db, userId: task.user_id, userKey, pushes: pushesToSend });
|
|
1571
|
+
const sentIds = [];
|
|
1572
|
+
try {
|
|
1573
|
+
for (let i = 0; i < pushesToSend.length; i++) {
|
|
1574
|
+
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(pushesToSend[i]));
|
|
1575
|
+
sentIds.push(pushesToSend[i].messageId);
|
|
1576
|
+
if (i < pushesToSend.length - 1) {
|
|
1577
|
+
await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
|
|
1578
|
+
}
|
|
1378
1579
|
}
|
|
1580
|
+
} finally {
|
|
1581
|
+
await markPushesDelivered({ db: ctx.db, userId: task.user_id, messageIds: sentIds });
|
|
1379
1582
|
}
|
|
1380
1583
|
return { success: true, messagesSent: messages.length };
|
|
1381
1584
|
} catch (error) {
|
|
1382
|
-
return {
|
|
1585
|
+
return {
|
|
1586
|
+
success: false,
|
|
1587
|
+
messagesSent: 0,
|
|
1588
|
+
error: error.message,
|
|
1589
|
+
errorCode: error.code || null,
|
|
1590
|
+
permanent: isNonRetryableError(error)
|
|
1591
|
+
};
|
|
1383
1592
|
}
|
|
1384
1593
|
}
|
|
1385
1594
|
async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
|
|
@@ -1411,18 +1620,35 @@ async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, provided
|
|
|
1411
1620
|
}
|
|
1412
1621
|
const result = await processSingleMessage(task, ctx, masterKey);
|
|
1413
1622
|
if (!result.success) {
|
|
1414
|
-
if (retryCount < maxRetries) {
|
|
1623
|
+
if (!result.permanent && retryCount < maxRetries) {
|
|
1415
1624
|
retryCount++;
|
|
1416
1625
|
await new Promise((resolve) => setTimeout(resolve, 1e3 * retryCount));
|
|
1417
1626
|
continue;
|
|
1418
1627
|
}
|
|
1419
1628
|
try {
|
|
1420
|
-
await ctx.db.updateTaskById(task.id, {
|
|
1629
|
+
await ctx.db.updateTaskById(task.id, {
|
|
1630
|
+
status: "failed",
|
|
1631
|
+
retry_count: retryCount,
|
|
1632
|
+
last_error: JSON.stringify({
|
|
1633
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1634
|
+
occurrence: task.next_send_at ?? null,
|
|
1635
|
+
reason: sanitizeErrorSummary(result.error)
|
|
1636
|
+
})
|
|
1637
|
+
});
|
|
1421
1638
|
} catch (_updateError) {
|
|
1639
|
+
try {
|
|
1640
|
+
await ctx.db.updateTaskById(task.id, { status: "failed", retry_count: retryCount });
|
|
1641
|
+
} catch (_retryError) {
|
|
1642
|
+
}
|
|
1422
1643
|
}
|
|
1423
1644
|
return {
|
|
1424
1645
|
success: false,
|
|
1425
|
-
error: {
|
|
1646
|
+
error: {
|
|
1647
|
+
code: "PROCESSING_ERROR",
|
|
1648
|
+
message: result.error,
|
|
1649
|
+
retriesAttempted: retryCount,
|
|
1650
|
+
...result.permanent ? { permanent: true } : {}
|
|
1651
|
+
}
|
|
1426
1652
|
};
|
|
1427
1653
|
}
|
|
1428
1654
|
try {
|
|
@@ -1457,16 +1683,12 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1457
1683
|
const masterKey = tenantCtx.masterKey;
|
|
1458
1684
|
const isEncrypted = getHeader(headers, "x-payload-encrypted") === "true";
|
|
1459
1685
|
const encryptionVersion = getHeader(headers, "x-encryption-version");
|
|
1460
|
-
const userId = getHeader(headers, "x-user-id");
|
|
1461
1686
|
if (!isEncrypted) {
|
|
1462
1687
|
return { status: 400, body: { success: false, error: { code: "ENCRYPTION_REQUIRED", message: "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6" } } };
|
|
1463
1688
|
}
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
}
|
|
1467
|
-
if (!isValidUUIDv4(userId)) {
|
|
1468
|
-
return { status: 400, body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } } };
|
|
1469
|
-
}
|
|
1689
|
+
const gate = requireUserId(headers);
|
|
1690
|
+
if (gate.error) return gate.error;
|
|
1691
|
+
const { userId } = gate;
|
|
1470
1692
|
if (encryptionVersion !== "1") {
|
|
1471
1693
|
return { status: 400, body: { success: false, error: { code: "UNSUPPORTED_ENCRYPTION_VERSION", message: "\u52A0\u5BC6\u7248\u672C\u4E0D\u652F\u6301" } } };
|
|
1472
1694
|
}
|
|
@@ -1495,13 +1717,22 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1495
1717
|
if (!supportsPushSubscriptionStore(db)) {
|
|
1496
1718
|
return { status: 501, body: { success: false, error: { code: "PUSH_SUBSCRIPTION_NOT_SUPPORTED", message: "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u7528\u6237\u7EA7\u63A8\u9001\u8BA2\u9605\u5B58\u50A8" } } };
|
|
1497
1719
|
}
|
|
1498
|
-
let
|
|
1720
|
+
let subscriptionRow;
|
|
1499
1721
|
try {
|
|
1500
|
-
|
|
1722
|
+
subscriptionRow = await db.getPushSubscription(userId);
|
|
1501
1723
|
} catch (_error) {
|
|
1502
|
-
|
|
1724
|
+
return {
|
|
1725
|
+
status: 503,
|
|
1726
|
+
body: {
|
|
1727
|
+
success: false,
|
|
1728
|
+
error: {
|
|
1729
|
+
code: "PUSH_SUBSCRIPTION_LOOKUP_FAILED",
|
|
1730
|
+
message: "\u63A8\u9001\u8BA2\u9605\u8BFB\u53D6\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
};
|
|
1503
1734
|
}
|
|
1504
|
-
if (!
|
|
1735
|
+
if (!subscriptionRow || typeof subscriptionRow.subscription !== "string" || !subscriptionRow.subscription) {
|
|
1505
1736
|
return {
|
|
1506
1737
|
status: 409,
|
|
1507
1738
|
body: {
|
|
@@ -1514,13 +1745,14 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1514
1745
|
};
|
|
1515
1746
|
}
|
|
1516
1747
|
const taskUuid = payload.uuid || randomUUID();
|
|
1748
|
+
const effectiveSendTime = payload.immediate === true ? (/* @__PURE__ */ new Date()).toISOString() : payload.firstSendTime;
|
|
1517
1749
|
const fullTaskData = {
|
|
1518
1750
|
contactName: payload.contactName,
|
|
1519
1751
|
avatarUrl: payload.avatarUrl || null,
|
|
1520
1752
|
messageType: payload.messageType,
|
|
1521
1753
|
messageSubtype: payload.messageSubtype || "chat",
|
|
1522
1754
|
userMessage: payload.userMessage || null,
|
|
1523
|
-
firstSendTime:
|
|
1755
|
+
firstSendTime: effectiveSendTime,
|
|
1524
1756
|
recurrenceType: payload.recurrenceType || "none",
|
|
1525
1757
|
// daily / weekly 按这个时区的墙钟推进(同一钟点,日期 +1 天 / +7 天)。
|
|
1526
1758
|
// 不传 → null → 按 UTC 推进。
|
|
@@ -1539,6 +1771,9 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1539
1771
|
// the message processor to chunk LLM output into individual pushes.
|
|
1540
1772
|
// null → processor falls back to the default /([。!?!?]+)/ regex.
|
|
1541
1773
|
splitPattern: payload.splitPattern ?? null,
|
|
1774
|
+
// 透传给 LLM 中转的非标准参数(thinking 之类),buildLlmRequestBody 会
|
|
1775
|
+
// 把它展开进请求体(核心字段优先)。
|
|
1776
|
+
llmExtraBody: payload.llmExtraBody ?? null,
|
|
1542
1777
|
metadata: payload.metadata || {}
|
|
1543
1778
|
};
|
|
1544
1779
|
const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey);
|
|
@@ -1563,15 +1798,26 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1563
1798
|
};
|
|
1564
1799
|
}
|
|
1565
1800
|
}
|
|
1801
|
+
const createParams = {
|
|
1802
|
+
user_id: userId,
|
|
1803
|
+
uuid: taskUuid,
|
|
1804
|
+
encrypted_payload: encryptedPayload,
|
|
1805
|
+
next_send_at: effectiveSendTime,
|
|
1806
|
+
message_type: payload.messageType
|
|
1807
|
+
};
|
|
1808
|
+
const supersedesUuid = payload.supersedesUuid || null;
|
|
1809
|
+
let superseded = false;
|
|
1566
1810
|
let dbResult;
|
|
1567
1811
|
try {
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1812
|
+
if (supersedesUuid && typeof db.createTaskSuperseding === "function") {
|
|
1813
|
+
dbResult = await db.createTaskSuperseding(createParams, supersedesUuid);
|
|
1814
|
+
superseded = !!(dbResult && dbResult.superseded);
|
|
1815
|
+
} else {
|
|
1816
|
+
if (supersedesUuid) {
|
|
1817
|
+
superseded = await db.deleteTaskByUuid(supersedesUuid, userId);
|
|
1818
|
+
}
|
|
1819
|
+
dbResult = await db.createTask(createParams);
|
|
1820
|
+
}
|
|
1575
1821
|
} catch (error) {
|
|
1576
1822
|
if (isUniqueViolation(error)) {
|
|
1577
1823
|
return {
|
|
@@ -1610,7 +1856,8 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1610
1856
|
messagesSent: sendResult.messagesSent,
|
|
1611
1857
|
sentAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1612
1858
|
status: "sent",
|
|
1613
|
-
retriesUsed: sendResult.retriesUsed || 0
|
|
1859
|
+
retriesUsed: sendResult.retriesUsed || 0,
|
|
1860
|
+
...supersedesUuid ? { superseded } : {}
|
|
1614
1861
|
}
|
|
1615
1862
|
}
|
|
1616
1863
|
};
|
|
@@ -1628,7 +1875,10 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1628
1875
|
contactName: payload.contactName,
|
|
1629
1876
|
nextSendAt: dbResult.next_send_at,
|
|
1630
1877
|
status: dbResult.status,
|
|
1631
|
-
createdAt: dbResult.created_at
|
|
1878
|
+
createdAt: dbResult.created_at,
|
|
1879
|
+
// supersede 的结果:true = 旧行确实被取消了;false = 旧行本就不存在
|
|
1880
|
+
// (已发出 / 已删除),新任务照常建立。
|
|
1881
|
+
...supersedesUuid ? { superseded } : {}
|
|
1632
1882
|
}
|
|
1633
1883
|
}
|
|
1634
1884
|
};
|
|
@@ -1639,7 +1889,12 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1639
1889
|
// src/server/lib/run-tick.js
|
|
1640
1890
|
var DEFAULT_CLAIM_LEASE_MS = 10 * 60 * 1e3;
|
|
1641
1891
|
var CLAIM_LEASE_MARGIN_MS = 2 * 60 * 1e3;
|
|
1892
|
+
var DEFAULT_LEASE_HEARTBEAT_MS = 30 * 1e3;
|
|
1893
|
+
var DEFAULT_HEARTBEAT_LEASE_TTL_MS = 90 * 1e3;
|
|
1642
1894
|
var STALE_AFTER_MS = 60 * 60 * 1e3;
|
|
1895
|
+
function resolveStaleAfterMs(ctx) {
|
|
1896
|
+
return positiveNumber(ctx.staleAfterMs) || STALE_AFTER_MS;
|
|
1897
|
+
}
|
|
1643
1898
|
function isRecurringType(recurrenceType) {
|
|
1644
1899
|
return recurrenceType === "daily" || recurrenceType === "weekly";
|
|
1645
1900
|
}
|
|
@@ -1653,12 +1908,47 @@ async function deriveSerializeGroup(userKey, rawKey) {
|
|
|
1653
1908
|
return bytesToBase64Url(await hmacSha256(utf8(userKey), utf8(rawKey)));
|
|
1654
1909
|
}
|
|
1655
1910
|
async function runScheduledTick(ctx) {
|
|
1911
|
+
const tasks = await ctx.db.getPendingTasks(50);
|
|
1912
|
+
const summary = await deliverTasks(ctx, tasks);
|
|
1913
|
+
await ctx.db.cleanupOldTasks(7);
|
|
1914
|
+
if (typeof ctx.db.cleanupOutbox === "function") {
|
|
1915
|
+
try {
|
|
1916
|
+
await ctx.db.cleanupOutbox({
|
|
1917
|
+
ackedBeforeMs: Date.now() - 7 * 24 * 60 * 60 * 1e3,
|
|
1918
|
+
allBeforeMs: Date.now() - 28 * 24 * 60 * 60 * 1e3
|
|
1919
|
+
});
|
|
1920
|
+
} catch (error) {
|
|
1921
|
+
console.warn("[amsg-server] cleanupOutbox \u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", error && error.message);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return summary;
|
|
1925
|
+
}
|
|
1926
|
+
async function runTask(ctx, uuid) {
|
|
1927
|
+
if (typeof uuid !== "string" || !uuid.trim()) {
|
|
1928
|
+
throw new TypeError("runTask(ctx, uuid) requires a non-empty uuid string");
|
|
1929
|
+
}
|
|
1930
|
+
const task = await ctx.db.getTaskByUuidOnly(uuid);
|
|
1931
|
+
if (!task) return { ran: false, reason: "not_found" };
|
|
1932
|
+
const dueMs = Date.parse(task.next_send_at);
|
|
1933
|
+
if (Number.isFinite(dueMs) && dueMs > Date.now()) {
|
|
1934
|
+
return { ran: false, reason: "not_due", nextSendAt: task.next_send_at };
|
|
1935
|
+
}
|
|
1936
|
+
const retryAfterMs = task.retry_after ? Date.parse(task.retry_after) : NaN;
|
|
1937
|
+
if (Number.isFinite(retryAfterMs) && retryAfterMs > Date.now()) {
|
|
1938
|
+
return { ran: false, reason: "retry_pending", retryAfter: task.retry_after };
|
|
1939
|
+
}
|
|
1940
|
+
return { ran: true, summary: await deliverTasks(ctx, [task]) };
|
|
1941
|
+
}
|
|
1942
|
+
async function deliverTasks(ctx, tasks) {
|
|
1656
1943
|
const db = ctx.db;
|
|
1657
1944
|
const masterKey = ctx.masterKey;
|
|
1658
1945
|
const claimLeaseMs = resolveClaimLeaseMs(ctx);
|
|
1946
|
+
const staleAfterMs = resolveStaleAfterMs(ctx);
|
|
1659
1947
|
const serializeBy = typeof ctx.serializeBy === "function" ? ctx.serializeBy : null;
|
|
1948
|
+
const heartbeatMs = ctx.leaseHeartbeatMs === 0 ? 0 : positiveNumber(ctx.leaseHeartbeatMs) || DEFAULT_LEASE_HEARTBEAT_MS;
|
|
1949
|
+
const heartbeatEnabled = heartbeatMs > 0 && typeof db.claimTask === "function" && typeof db.renewTaskLease === "function";
|
|
1950
|
+
const heartbeatLeaseTtlMs = Math.max(DEFAULT_HEARTBEAT_LEASE_TTL_MS, heartbeatMs * 2);
|
|
1660
1951
|
const startTime = Date.now();
|
|
1661
|
-
const tasks = await db.getPendingTasks(50);
|
|
1662
1952
|
const MAX_CONCURRENT = 8;
|
|
1663
1953
|
const results = {
|
|
1664
1954
|
totalTasks: tasks.length,
|
|
@@ -1675,11 +1965,57 @@ async function runScheduledTick(ctx) {
|
|
|
1675
1965
|
const supportsClaim = typeof db.claimTask === "function";
|
|
1676
1966
|
async function claimForThisTick(task, serializeGroup) {
|
|
1677
1967
|
if (!supportsClaim) return true;
|
|
1678
|
-
const leaseUntil = new Date(
|
|
1968
|
+
const leaseUntil = new Date(
|
|
1969
|
+
Date.now() + (heartbeatEnabled ? heartbeatLeaseTtlMs : claimLeaseMs)
|
|
1970
|
+
).toISOString();
|
|
1679
1971
|
return !!await db.claimTask(task.id, task.next_send_at, leaseUntil, serializeGroup);
|
|
1680
1972
|
}
|
|
1973
|
+
function startLeaseHeartbeat(task) {
|
|
1974
|
+
if (!heartbeatEnabled) return () => {
|
|
1975
|
+
};
|
|
1976
|
+
let stopped = false;
|
|
1977
|
+
let timer = null;
|
|
1978
|
+
const beat = async () => {
|
|
1979
|
+
if (stopped) return;
|
|
1980
|
+
try {
|
|
1981
|
+
await db.renewTaskLease(task.id, new Date(Date.now() + heartbeatLeaseTtlMs).toISOString());
|
|
1982
|
+
} catch (error) {
|
|
1983
|
+
console.warn("[amsg-server] \u79DF\u7EA6\u7EED\u79DF\u5931\u8D25\uFF08\u4E0B\u4E2A\u5FC3\u8DF3\u518D\u8BD5\uFF09:", error && error.message);
|
|
1984
|
+
}
|
|
1985
|
+
if (!stopped) schedule();
|
|
1986
|
+
};
|
|
1987
|
+
const schedule = () => {
|
|
1988
|
+
timer = setTimeout(beat, heartbeatMs);
|
|
1989
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
1990
|
+
};
|
|
1991
|
+
schedule();
|
|
1992
|
+
return () => {
|
|
1993
|
+
stopped = true;
|
|
1994
|
+
if (timer) clearTimeout(timer);
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
const supportsLastError = supportsClaim;
|
|
1998
|
+
async function updateTaskWithLastError(taskId, fields) {
|
|
1999
|
+
try {
|
|
2000
|
+
return await db.updateTaskById(taskId, fields);
|
|
2001
|
+
} catch (error) {
|
|
2002
|
+
if (Object.prototype.hasOwnProperty.call(fields, "last_error") && /last_error/i.test(error.message || "")) {
|
|
2003
|
+
const { last_error: _omit, ...rest } = fields;
|
|
2004
|
+
return db.updateTaskById(taskId, rest);
|
|
2005
|
+
}
|
|
2006
|
+
throw error;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
function lastErrorJson(task, reason, extra) {
|
|
2010
|
+
return JSON.stringify({
|
|
2011
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2012
|
+
occurrence: task.next_send_at ?? null,
|
|
2013
|
+
reason: sanitizeErrorSummary(reason),
|
|
2014
|
+
...extra || {}
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
1681
2017
|
async function updateAndRelease(taskId, fields) {
|
|
1682
|
-
return
|
|
2018
|
+
return updateTaskWithLastError(
|
|
1683
2019
|
taskId,
|
|
1684
2020
|
supportsClaim ? { ...fields, lease_until: null, retry_after: null } : fields
|
|
1685
2021
|
);
|
|
@@ -1703,8 +2039,9 @@ async function runScheduledTick(ctx) {
|
|
|
1703
2039
|
return { taken: true, rawKey: null };
|
|
1704
2040
|
}
|
|
1705
2041
|
if (typeof rawKey !== "string" || !rawKey) return { taken: false, rawKey: null };
|
|
1706
|
-
|
|
1707
|
-
groupsTakenThisTick.
|
|
2042
|
+
const scopedKey = `${task.user_id}\0${rawKey}`;
|
|
2043
|
+
if (groupsTakenThisTick.has(scopedKey)) return { taken: true, rawKey };
|
|
2044
|
+
groupsTakenThisTick.add(scopedKey);
|
|
1708
2045
|
return { taken: false, rawKey };
|
|
1709
2046
|
}
|
|
1710
2047
|
async function encryptPayloadWithLastError(task, decryptedPayload, userKey, reason, extra) {
|
|
@@ -1731,34 +2068,38 @@ async function runScheduledTick(ctx) {
|
|
|
1731
2068
|
console.warn("[amsg-server] onStaleSkip hook \u629B\u9519\uFF08\u5DF2\u5FFD\u7565\uFF09:", hookError && hookError.message);
|
|
1732
2069
|
}
|
|
1733
2070
|
}
|
|
1734
|
-
async function handleDeliveryFailure(task, reason, recurrenceType, decryptedPayload, userKey) {
|
|
2071
|
+
async function handleDeliveryFailure(task, reason, recurrenceType, decryptedPayload, userKey, errorCode = null, permanentFlag = false) {
|
|
1735
2072
|
results.failedCount++;
|
|
1736
2073
|
const tzId = decryptedPayload ? decryptedPayload.tzId ?? null : null;
|
|
2074
|
+
const permanent = permanentFlag === true || errorCode === "PUSH_SUBSCRIPTION_MISSING" || errorCode === "PUSH_SUBSCRIPTION_STORE_UNSUPPORTED";
|
|
1737
2075
|
try {
|
|
1738
|
-
if (task.retry_count >= 3) {
|
|
2076
|
+
if (permanent || task.retry_count >= 3) {
|
|
1739
2077
|
const encrypted = await encryptPayloadWithLastError(task, decryptedPayload, userKey, reason);
|
|
1740
2078
|
if (isRecurringType(recurrenceType)) {
|
|
1741
2079
|
const nextSendAt = nextFutureOccurrence(Date.parse(task.next_send_at), recurrenceType, Date.now(), tzId);
|
|
1742
2080
|
await updateAndRelease(task.id, {
|
|
1743
2081
|
next_send_at: nextSendAt,
|
|
1744
2082
|
retry_count: 0,
|
|
1745
|
-
...encrypted ? { encrypted_payload: encrypted } : {}
|
|
2083
|
+
...encrypted ? { encrypted_payload: encrypted } : {},
|
|
2084
|
+
...supportsLastError ? { last_error: lastErrorJson(task, reason) } : {}
|
|
1746
2085
|
});
|
|
1747
|
-
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "occurrence_skipped", nextSendAt });
|
|
2086
|
+
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "occurrence_skipped", nextSendAt, ...permanent ? { permanent: true } : {} });
|
|
1748
2087
|
} else {
|
|
1749
2088
|
await updateAndRelease(task.id, {
|
|
1750
2089
|
status: "failed",
|
|
1751
|
-
...encrypted ? { encrypted_payload: encrypted } : {}
|
|
2090
|
+
...encrypted ? { encrypted_payload: encrypted } : {},
|
|
2091
|
+
...supportsLastError ? { last_error: lastErrorJson(task, reason) } : {}
|
|
1752
2092
|
});
|
|
1753
|
-
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed" });
|
|
2093
|
+
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed", ...permanent ? { permanent: true } : {} });
|
|
1754
2094
|
}
|
|
1755
2095
|
} else {
|
|
1756
2096
|
const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1e3);
|
|
1757
2097
|
if (supportsClaim) {
|
|
1758
|
-
await
|
|
2098
|
+
await updateTaskWithLastError(task.id, {
|
|
1759
2099
|
retry_after: nextRetryTime.toISOString(),
|
|
1760
2100
|
lease_until: null,
|
|
1761
|
-
retry_count: task.retry_count + 1
|
|
2101
|
+
retry_count: task.retry_count + 1,
|
|
2102
|
+
...supportsLastError ? { last_error: lastErrorJson(task, reason) } : {}
|
|
1762
2103
|
});
|
|
1763
2104
|
} else {
|
|
1764
2105
|
await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
|
|
@@ -1819,6 +2160,16 @@ async function runScheduledTick(ctx) {
|
|
|
1819
2160
|
results.claimSkippedTasks++;
|
|
1820
2161
|
return;
|
|
1821
2162
|
}
|
|
2163
|
+
const stopHeartbeat = startLeaseHeartbeat(task);
|
|
2164
|
+
try {
|
|
2165
|
+
await deliverClaimedTask(task, decrypted);
|
|
2166
|
+
} finally {
|
|
2167
|
+
stopHeartbeat();
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
async function deliverClaimedTask(task, decrypted) {
|
|
2171
|
+
const decryptedPayload = decrypted.ok ? decrypted.payload : null;
|
|
2172
|
+
const userKey = decrypted.ok ? decrypted.userKey : null;
|
|
1822
2173
|
if (!decrypted.ok) {
|
|
1823
2174
|
await handleDeliveryFailure(task, decrypted.error && decrypted.error.message || "\u4EFB\u52A1\u8F7D\u8377\u89E3\u5BC6\u5931\u8D25", null, null, null);
|
|
1824
2175
|
return;
|
|
@@ -1826,7 +2177,9 @@ async function runScheduledTick(ctx) {
|
|
|
1826
2177
|
const recurrenceType = decryptedPayload.recurrenceType;
|
|
1827
2178
|
const tzId = decryptedPayload.tzId ?? null;
|
|
1828
2179
|
const occurrenceMs = Date.parse(task.next_send_at);
|
|
1829
|
-
|
|
2180
|
+
const retryAfterMs = task.retry_after ? Date.parse(task.retry_after) : NaN;
|
|
2181
|
+
const notOnFreshRetryChain = (task.retry_count || 0) === 0 || Number.isFinite(retryAfterMs) && Date.now() - retryAfterMs > staleAfterMs;
|
|
2182
|
+
if (Number.isFinite(occurrenceMs) && Date.now() - occurrenceMs > staleAfterMs && notOnFreshRetryChain) {
|
|
1830
2183
|
try {
|
|
1831
2184
|
const stateAccessors = createStateAccessors({
|
|
1832
2185
|
db,
|
|
@@ -1834,57 +2187,42 @@ async function runScheduledTick(ctx) {
|
|
|
1834
2187
|
userKey,
|
|
1835
2188
|
maxStateValueBytes: ctx.maxStateValueBytes
|
|
1836
2189
|
});
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
});
|
|
1874
|
-
results.staleTasks.push({ taskId: task.id, reason: "stale", action: "expired", skippedCount: 1 });
|
|
1875
|
-
await notifyStaleSkip(task, {
|
|
1876
|
-
reason: "stale",
|
|
1877
|
-
action: "expired",
|
|
1878
|
-
metadata: decryptedPayload.metadata ?? null,
|
|
1879
|
-
recurrenceType: recurrenceType || "none",
|
|
1880
|
-
occurrenceMs,
|
|
1881
|
-
skippedCount: 1,
|
|
1882
|
-
skippedOccurrences: [occurrenceMs],
|
|
1883
|
-
skippedTruncated: false,
|
|
1884
|
-
nextSendAt: null,
|
|
1885
|
-
...stateAccessors
|
|
1886
|
-
});
|
|
1887
|
-
}
|
|
2190
|
+
const recurring = isRecurringType(recurrenceType);
|
|
2191
|
+
const plan = recurring ? planNextOccurrence(occurrenceMs, recurrenceType, Date.now(), tzId) : null;
|
|
2192
|
+
const nextSendAt = recurring ? new Date(plan.nextMs).toISOString() : null;
|
|
2193
|
+
const action = recurring ? "fast_forwarded" : "expired";
|
|
2194
|
+
const skippedCount = recurring ? plan.skippedCount : 1;
|
|
2195
|
+
const encrypted = await encryptPayloadWithLastError(
|
|
2196
|
+
task,
|
|
2197
|
+
decryptedPayload,
|
|
2198
|
+
userKey,
|
|
2199
|
+
"stale",
|
|
2200
|
+
recurring ? { skippedCount, nextSendAt } : void 0
|
|
2201
|
+
);
|
|
2202
|
+
await updateAndRelease(task.id, {
|
|
2203
|
+
...recurring ? { next_send_at: nextSendAt, retry_count: 0 } : { status: "failed" },
|
|
2204
|
+
...encrypted ? { encrypted_payload: encrypted } : {},
|
|
2205
|
+
...supportsLastError ? { last_error: lastErrorJson(task, "stale", recurring ? { skippedCount, nextSendAt } : void 0) } : {}
|
|
2206
|
+
});
|
|
2207
|
+
results.staleTasks.push({
|
|
2208
|
+
taskId: task.id,
|
|
2209
|
+
reason: "stale",
|
|
2210
|
+
action,
|
|
2211
|
+
...recurring ? { nextSendAt } : {},
|
|
2212
|
+
skippedCount
|
|
2213
|
+
});
|
|
2214
|
+
await notifyStaleSkip(task, {
|
|
2215
|
+
reason: "stale",
|
|
2216
|
+
action,
|
|
2217
|
+
metadata: decryptedPayload.metadata ?? null,
|
|
2218
|
+
recurrenceType: recurrenceType || "none",
|
|
2219
|
+
occurrenceMs,
|
|
2220
|
+
skippedCount,
|
|
2221
|
+
skippedOccurrences: recurring ? plan.skippedOccurrences : [occurrenceMs],
|
|
2222
|
+
skippedTruncated: recurring ? plan.skippedTruncated : false,
|
|
2223
|
+
nextSendAt,
|
|
2224
|
+
...stateAccessors
|
|
2225
|
+
});
|
|
1888
2226
|
} catch (error) {
|
|
1889
2227
|
results.failedCount++;
|
|
1890
2228
|
results.failedTasks.push({ taskId: task.id, reason: error.message || "\u8FC7\u671F\u4EFB\u52A1\u5904\u7406\u5931\u8D25", status: "stale_update_failed" });
|
|
@@ -1893,13 +2231,34 @@ async function runScheduledTick(ctx) {
|
|
|
1893
2231
|
}
|
|
1894
2232
|
let sendResult;
|
|
1895
2233
|
try {
|
|
1896
|
-
sendResult = await processSingleMessage(
|
|
1897
|
-
|
|
1898
|
-
|
|
2234
|
+
sendResult = await processSingleMessage(
|
|
2235
|
+
task,
|
|
2236
|
+
{ ...ctx, db, masterKey },
|
|
2237
|
+
masterKey,
|
|
2238
|
+
{ userKey, payload: decryptedPayload }
|
|
2239
|
+
);
|
|
2240
|
+
} catch (error) {
|
|
2241
|
+
await handleDeliveryFailure(
|
|
2242
|
+
task,
|
|
2243
|
+
error.message || "\u6D88\u606F\u53D1\u9001\u5931\u8D25",
|
|
2244
|
+
recurrenceType,
|
|
2245
|
+
decryptedPayload,
|
|
2246
|
+
userKey,
|
|
2247
|
+
error.code || null,
|
|
2248
|
+
error.permanent === true
|
|
2249
|
+
);
|
|
1899
2250
|
return;
|
|
1900
2251
|
}
|
|
1901
2252
|
if (!sendResult.success) {
|
|
1902
|
-
await handleDeliveryFailure(
|
|
2253
|
+
await handleDeliveryFailure(
|
|
2254
|
+
task,
|
|
2255
|
+
sendResult.error || "\u6D88\u606F\u53D1\u9001\u5931\u8D25",
|
|
2256
|
+
recurrenceType,
|
|
2257
|
+
decryptedPayload,
|
|
2258
|
+
userKey,
|
|
2259
|
+
sendResult.errorCode || null,
|
|
2260
|
+
sendResult.permanent === true
|
|
2261
|
+
);
|
|
1903
2262
|
return;
|
|
1904
2263
|
}
|
|
1905
2264
|
try {
|
|
@@ -1908,7 +2267,11 @@ async function runScheduledTick(ctx) {
|
|
|
1908
2267
|
results.deletedOnceOffTasks++;
|
|
1909
2268
|
} else {
|
|
1910
2269
|
const nextSendAt = nextFutureOccurrence(occurrenceMs, recurrenceType, Date.now(), tzId);
|
|
1911
|
-
await updateAndRelease(task.id, {
|
|
2270
|
+
await updateAndRelease(task.id, {
|
|
2271
|
+
next_send_at: nextSendAt,
|
|
2272
|
+
retry_count: 0,
|
|
2273
|
+
...supportsLastError ? { last_error: null } : {}
|
|
2274
|
+
});
|
|
1912
2275
|
results.updatedRecurringTasks++;
|
|
1913
2276
|
}
|
|
1914
2277
|
results.successCount++;
|
|
@@ -1941,7 +2304,6 @@ async function runScheduledTick(ctx) {
|
|
|
1941
2304
|
await Promise.race(processing);
|
|
1942
2305
|
}
|
|
1943
2306
|
}
|
|
1944
|
-
await db.cleanupOldTasks(7);
|
|
1945
2307
|
const executionTime = Date.now() - startTime;
|
|
1946
2308
|
return {
|
|
1947
2309
|
totalTasks: results.totalTasks,
|
|
@@ -1977,13 +2339,9 @@ function createUpdateMessageHandler(ctx) {
|
|
|
1977
2339
|
if (!taskUuid) {
|
|
1978
2340
|
return { status: 400, body: { success: false, error: { code: "TASK_ID_REQUIRED", message: "\u7F3A\u5C11\u4EFB\u52A1ID" } } };
|
|
1979
2341
|
}
|
|
1980
|
-
const
|
|
1981
|
-
if (
|
|
1982
|
-
|
|
1983
|
-
}
|
|
1984
|
-
if (!isValidUUIDv4(userId)) {
|
|
1985
|
-
return { status: 400, body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } } };
|
|
1986
|
-
}
|
|
2342
|
+
const gate = requireUserId(headers);
|
|
2343
|
+
if (gate.error) return gate.error;
|
|
2344
|
+
const { userId } = gate;
|
|
1987
2345
|
const isEncrypted = getHeader(headers, "x-payload-encrypted") === "true";
|
|
1988
2346
|
const encryptionVersion = getHeader(headers, "x-encryption-version");
|
|
1989
2347
|
if (!isEncrypted) {
|
|
@@ -2092,7 +2450,11 @@ function createUpdateMessageHandler(ctx) {
|
|
|
2092
2450
|
...Object.prototype.hasOwnProperty.call(updates, "splitPattern") && { splitPattern: updates.splitPattern ?? null }
|
|
2093
2451
|
};
|
|
2094
2452
|
const encryptedPayload = await encryptForStorage(JSON.stringify(updatedData), userKey);
|
|
2095
|
-
const extraFields =
|
|
2453
|
+
const extraFields = {
|
|
2454
|
+
retry_count: 0,
|
|
2455
|
+
...typeof db.claimTask === "function" ? { retry_after: null } : {},
|
|
2456
|
+
...updates.nextSendAt ? { next_send_at: updates.nextSendAt } : {}
|
|
2457
|
+
};
|
|
2096
2458
|
const result = await db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields);
|
|
2097
2459
|
if (!result) {
|
|
2098
2460
|
return { status: 409, body: { success: false, error: { code: "UPDATE_CONFLICT", message: "\u4EFB\u52A1\u66F4\u65B0\u5931\u8D25\uFF0C\u4EFB\u52A1\u53EF\u80FD\u5DF2\u88AB\u4FEE\u6539\u6216\u5220\u9664" } } };
|
|
@@ -2125,13 +2487,9 @@ function createCancelMessageHandler(ctx) {
|
|
|
2125
2487
|
if (!taskUuid) {
|
|
2126
2488
|
return { status: 400, body: { success: false, error: { code: "TASK_ID_REQUIRED", message: "\u7F3A\u5C11\u4EFB\u52A1ID" } } };
|
|
2127
2489
|
}
|
|
2128
|
-
const
|
|
2129
|
-
if (
|
|
2130
|
-
|
|
2131
|
-
}
|
|
2132
|
-
if (!isValidUUIDv4(userId)) {
|
|
2133
|
-
return { status: 400, body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } } };
|
|
2134
|
-
}
|
|
2490
|
+
const gate = requireUserId(headers);
|
|
2491
|
+
if (gate.error) return gate.error;
|
|
2492
|
+
const { userId } = gate;
|
|
2135
2493
|
const deleted = await db.deleteTaskByUuid(taskUuid, userId);
|
|
2136
2494
|
if (!deleted) {
|
|
2137
2495
|
return {
|
|
@@ -2160,19 +2518,9 @@ function createMessagesHandler(ctx) {
|
|
|
2160
2518
|
const tenantCtx = tenantResult.context;
|
|
2161
2519
|
const db = tenantCtx.db;
|
|
2162
2520
|
const masterKey = tenantCtx.masterKey;
|
|
2163
|
-
const
|
|
2164
|
-
if (
|
|
2165
|
-
|
|
2166
|
-
status: 400,
|
|
2167
|
-
body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u5FC5\u987B\u63D0\u4F9B X-User-Id \u8BF7\u6C42\u5934" } }
|
|
2168
|
-
};
|
|
2169
|
-
}
|
|
2170
|
-
if (!isValidUUIDv4(userId)) {
|
|
2171
|
-
return {
|
|
2172
|
-
status: 400,
|
|
2173
|
-
body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } }
|
|
2174
|
-
};
|
|
2175
|
-
}
|
|
2521
|
+
const gate = requireUserId(headers);
|
|
2522
|
+
if (gate.error) return gate.error;
|
|
2523
|
+
const { userId } = gate;
|
|
2176
2524
|
const u = new URL(url, "https://dummy");
|
|
2177
2525
|
const status = u.searchParams.get("status") || "all";
|
|
2178
2526
|
const limit = Math.min(parseInt(u.searchParams.get("limit") || "20", 10), 100);
|
|
@@ -2217,30 +2565,39 @@ function createGetMessageHandler(ctx) {
|
|
|
2217
2565
|
const tenantCtx = tenantResult.context;
|
|
2218
2566
|
const db = tenantCtx.db;
|
|
2219
2567
|
const masterKey = tenantCtx.masterKey;
|
|
2220
|
-
const
|
|
2221
|
-
if (
|
|
2222
|
-
|
|
2223
|
-
status: 400,
|
|
2224
|
-
body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u5FC5\u987B\u63D0\u4F9B X-User-Id \u8BF7\u6C42\u5934" } }
|
|
2225
|
-
};
|
|
2226
|
-
}
|
|
2227
|
-
if (!isValidUUIDv4(userId)) {
|
|
2228
|
-
return {
|
|
2229
|
-
status: 400,
|
|
2230
|
-
body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } }
|
|
2231
|
-
};
|
|
2232
|
-
}
|
|
2568
|
+
const gate = requireUserId(headers);
|
|
2569
|
+
if (gate.error) return gate.error;
|
|
2570
|
+
const { userId } = gate;
|
|
2233
2571
|
const taskUuid = new URL(url, "https://dummy").searchParams.get("id");
|
|
2234
2572
|
if (!taskUuid) {
|
|
2235
2573
|
return { status: 400, body: { success: false, error: { code: "TASK_ID_REQUIRED", message: "\u7F3A\u5C11\u4EFB\u52A1ID" } } };
|
|
2236
2574
|
}
|
|
2237
2575
|
const row = await db.getTaskByUuid(taskUuid, userId);
|
|
2238
2576
|
if (!row) {
|
|
2239
|
-
const
|
|
2577
|
+
const statusInfo = typeof db.getTaskStatusInfo === "function" ? await db.getTaskStatusInfo(taskUuid, userId) : null;
|
|
2578
|
+
const taskStatus = statusInfo ? statusInfo.status : await db.getTaskStatus(taskUuid, userId);
|
|
2240
2579
|
if (!taskStatus) {
|
|
2241
2580
|
return { status: 404, body: { success: false, error: { code: "TASK_NOT_FOUND", message: "\u6307\u5B9A\u7684\u4EFB\u52A1\u4E0D\u5B58\u5728\u6216\u5DF2\u88AB\u5220\u9664" } } };
|
|
2242
2581
|
}
|
|
2243
|
-
|
|
2582
|
+
let lastError = null;
|
|
2583
|
+
if (statusInfo && statusInfo.last_error) {
|
|
2584
|
+
try {
|
|
2585
|
+
lastError = JSON.parse(statusInfo.last_error);
|
|
2586
|
+
} catch (_parseError) {
|
|
2587
|
+
lastError = { reason: statusInfo.last_error };
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
return {
|
|
2591
|
+
status: 409,
|
|
2592
|
+
body: {
|
|
2593
|
+
success: false,
|
|
2594
|
+
error: {
|
|
2595
|
+
code: "TASK_ALREADY_COMPLETED",
|
|
2596
|
+
message: "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u6216\u5DF2\u5931\u8D25\uFF0C\u65E0\u6CD5\u66F4\u65B0",
|
|
2597
|
+
details: { status: taskStatus, lastError }
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
};
|
|
2244
2601
|
}
|
|
2245
2602
|
const userKey = await deriveUserEncryptionKey(userId, masterKey);
|
|
2246
2603
|
const decrypted = JSON.parse(await decryptFromStorage(row.encrypted_payload, userKey));
|
|
@@ -2263,12 +2620,6 @@ function err(status, code, message, details) {
|
|
|
2263
2620
|
const error = details === void 0 ? { code, message } : { code, message, details };
|
|
2264
2621
|
return { status, body: { success: false, error } };
|
|
2265
2622
|
}
|
|
2266
|
-
function requireUserId(headers) {
|
|
2267
|
-
const userId = getHeader(headers, "x-user-id");
|
|
2268
|
-
if (!userId) return { error: err(400, "USER_ID_REQUIRED", "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26") };
|
|
2269
|
-
if (!isValidUUIDv4(userId)) return { error: err(400, "INVALID_USER_ID_FORMAT", "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F") };
|
|
2270
|
-
return { userId };
|
|
2271
|
-
}
|
|
2272
2623
|
var UNSUPPORTED = err(
|
|
2273
2624
|
501,
|
|
2274
2625
|
"PUSH_SUBSCRIPTION_NOT_SUPPORTED",
|
|
@@ -2376,6 +2727,7 @@ var SQLITE_TABLE_SQL = `
|
|
|
2376
2727
|
serialize_group TEXT,
|
|
2377
2728
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
|
2378
2729
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
2730
|
+
last_error TEXT,
|
|
2379
2731
|
created_at TEXT NOT NULL,
|
|
2380
2732
|
updated_at TEXT NOT NULL
|
|
2381
2733
|
)
|
|
@@ -2395,6 +2747,14 @@ var SQLITE_MIGRATIONS = [
|
|
|
2395
2747
|
name: "add_serialize_group",
|
|
2396
2748
|
sql: "ALTER TABLE scheduled_messages ADD COLUMN serialize_group TEXT",
|
|
2397
2749
|
description: "Serialization group for runScheduledTick serializeBy (2.6.0)"
|
|
2750
|
+
},
|
|
2751
|
+
{
|
|
2752
|
+
name: "add_last_error",
|
|
2753
|
+
// 上一次投递失败的脱敏摘要(JSON:{ at, occurrence, reason })。payload 里
|
|
2754
|
+
// 的 lastError 是密文里的完整记录,这一列是它的明文出口——payload 本身
|
|
2755
|
+
// 解密失败时也写得进去,GET /message 对已失败的行也读得出来。
|
|
2756
|
+
sql: "ALTER TABLE scheduled_messages ADD COLUMN last_error TEXT",
|
|
2757
|
+
description: "Sanitized last delivery-failure summary (2.6.0)"
|
|
2398
2758
|
}
|
|
2399
2759
|
];
|
|
2400
2760
|
var SQLITE_INDEXES = [
|
|
@@ -2463,22 +2823,29 @@ var PUSH_SUBSCRIPTION_TABLE_SQL = `
|
|
|
2463
2823
|
updated_at INTEGER NOT NULL
|
|
2464
2824
|
)
|
|
2465
2825
|
`;
|
|
2826
|
+
var MESSAGE_OUTBOX_TABLE_SQL = `
|
|
2827
|
+
CREATE TABLE IF NOT EXISTS message_outbox (
|
|
2828
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2829
|
+
user_id TEXT NOT NULL,
|
|
2830
|
+
message_id TEXT NOT NULL,
|
|
2831
|
+
task_uuid TEXT,
|
|
2832
|
+
session_id TEXT,
|
|
2833
|
+
message_index INTEGER,
|
|
2834
|
+
total_messages INTEGER,
|
|
2835
|
+
payload TEXT NOT NULL,
|
|
2836
|
+
created_at INTEGER NOT NULL,
|
|
2837
|
+
delivered_at INTEGER,
|
|
2838
|
+
acked_at INTEGER,
|
|
2839
|
+
UNIQUE (user_id, message_id)
|
|
2840
|
+
)
|
|
2841
|
+
`;
|
|
2842
|
+
var MESSAGE_OUTBOX_INDEX_SQL = `
|
|
2843
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_unacked
|
|
2844
|
+
ON message_outbox (user_id, id)
|
|
2845
|
+
WHERE acked_at IS NULL
|
|
2846
|
+
`;
|
|
2466
2847
|
|
|
2467
2848
|
// src/server/adapters/d1.js
|
|
2468
|
-
var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
|
|
2469
|
-
"user_id",
|
|
2470
|
-
"uuid",
|
|
2471
|
-
"encrypted_payload",
|
|
2472
|
-
"message_type",
|
|
2473
|
-
"next_send_at",
|
|
2474
|
-
"lease_until",
|
|
2475
|
-
"retry_after",
|
|
2476
|
-
"serialize_group",
|
|
2477
|
-
"status",
|
|
2478
|
-
"retry_count",
|
|
2479
|
-
"created_at",
|
|
2480
|
-
"updated_at"
|
|
2481
|
-
]);
|
|
2482
2849
|
function escapeLikePrefix(prefix) {
|
|
2483
2850
|
return prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
2484
2851
|
}
|
|
@@ -2503,6 +2870,8 @@ var D1Adapter = class {
|
|
|
2503
2870
|
await this._db.prepare(SQLITE_TABLE_SQL).run();
|
|
2504
2871
|
await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
|
|
2505
2872
|
await this._db.prepare(PUSH_SUBSCRIPTION_TABLE_SQL).run();
|
|
2873
|
+
await this._db.prepare(MESSAGE_OUTBOX_TABLE_SQL).run();
|
|
2874
|
+
await this._db.prepare(MESSAGE_OUTBOX_INDEX_SQL).run();
|
|
2506
2875
|
for (const migration of SQLITE_MIGRATIONS) {
|
|
2507
2876
|
try {
|
|
2508
2877
|
await this._db.prepare(migration.sql).run();
|
|
@@ -2538,6 +2907,7 @@ var D1Adapter = class {
|
|
|
2538
2907
|
await this._db.prepare("DROP TABLE IF EXISTS scheduled_messages").run();
|
|
2539
2908
|
await this._db.prepare("DROP TABLE IF EXISTS client_state").run();
|
|
2540
2909
|
await this._db.prepare("DROP TABLE IF EXISTS push_subscriptions").run();
|
|
2910
|
+
await this._db.prepare("DROP TABLE IF EXISTS message_outbox").run();
|
|
2541
2911
|
}
|
|
2542
2912
|
async createTask(params) {
|
|
2543
2913
|
const now = this._now();
|
|
@@ -2552,9 +2922,48 @@ var D1Adapter = class {
|
|
|
2552
2922
|
`SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?`
|
|
2553
2923
|
).bind(id).first();
|
|
2554
2924
|
}
|
|
2925
|
+
/**
|
|
2926
|
+
* 建新任务的同时取消旧的那条(`POST /schedule-message` 的 supersedesUuid)。
|
|
2927
|
+
*
|
|
2928
|
+
* 两条语句走一次 batch(D1 的隐式事务 + 单次网络往返):不会出现「旧的删了、
|
|
2929
|
+
* 新的没建成」的中间态——INSERT 撞 uuid 唯一索引时整个 batch 回滚,旧行原样
|
|
2930
|
+
* 留着,调用方按既有的 409 冲突路径处理。
|
|
2931
|
+
*
|
|
2932
|
+
* @param {import('./interface.js').InsertTaskParams} params
|
|
2933
|
+
* @param {string} supersedesUuid - 要取消的旧任务 uuid(同一 user_id 下)
|
|
2934
|
+
* @returns {Promise<Object>} createTask 的返回行 + `superseded`(旧行是否
|
|
2935
|
+
* 真的被删掉;false = 旧行本就不存在)
|
|
2936
|
+
*/
|
|
2937
|
+
async createTaskSuperseding(params, supersedesUuid) {
|
|
2938
|
+
const now = this._now();
|
|
2939
|
+
const nextSendAt = this._iso(params.next_send_at);
|
|
2940
|
+
const statements = [
|
|
2941
|
+
this._db.prepare(
|
|
2942
|
+
"DELETE FROM scheduled_messages WHERE uuid = ? AND user_id = ?"
|
|
2943
|
+
).bind(supersedesUuid, params.user_id),
|
|
2944
|
+
this._db.prepare(
|
|
2945
|
+
`INSERT INTO scheduled_messages
|
|
2946
|
+
(user_id, uuid, encrypted_payload, next_send_at, message_type, status, retry_count, created_at, updated_at)
|
|
2947
|
+
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`
|
|
2948
|
+
).bind(params.user_id, params.uuid, params.encrypted_payload, nextSendAt, params.message_type, now, now)
|
|
2949
|
+
];
|
|
2950
|
+
let results;
|
|
2951
|
+
if (typeof this._db.batch === "function") {
|
|
2952
|
+
results = await this._db.batch(statements);
|
|
2953
|
+
} else {
|
|
2954
|
+
results = [];
|
|
2955
|
+
for (const stmt of statements) results.push(await stmt.run());
|
|
2956
|
+
}
|
|
2957
|
+
const superseded = (results[0].meta.changes || 0) > 0;
|
|
2958
|
+
const id = results[1].meta.last_row_id;
|
|
2959
|
+
const row = await this._db.prepare(
|
|
2960
|
+
"SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?"
|
|
2961
|
+
).bind(id).first();
|
|
2962
|
+
return { ...row, superseded };
|
|
2963
|
+
}
|
|
2555
2964
|
async getTaskByUuid(uuid, userId) {
|
|
2556
2965
|
return this._db.prepare(
|
|
2557
|
-
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at
|
|
2966
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, last_error, created_at, updated_at
|
|
2558
2967
|
FROM scheduled_messages
|
|
2559
2968
|
WHERE uuid = ? AND user_id = ? AND status = 'pending'
|
|
2560
2969
|
LIMIT 1`
|
|
@@ -2562,7 +2971,7 @@ var D1Adapter = class {
|
|
|
2562
2971
|
}
|
|
2563
2972
|
async getTaskByUuidOnly(uuid) {
|
|
2564
2973
|
return this._db.prepare(
|
|
2565
|
-
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
|
|
2974
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, retry_after, status, retry_count
|
|
2566
2975
|
FROM scheduled_messages
|
|
2567
2976
|
WHERE uuid = ? AND status = 'pending'
|
|
2568
2977
|
LIMIT 1`
|
|
@@ -2622,7 +3031,7 @@ var D1Adapter = class {
|
|
|
2622
3031
|
async getPendingTasks(limit = 50) {
|
|
2623
3032
|
const now = this._now();
|
|
2624
3033
|
const res = await this._db.prepare(
|
|
2625
|
-
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
|
|
3034
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, retry_after, status, retry_count
|
|
2626
3035
|
FROM scheduled_messages
|
|
2627
3036
|
WHERE status = 'pending' AND next_send_at <= ?
|
|
2628
3037
|
AND (lease_until IS NULL OR lease_until <= ?)
|
|
@@ -2695,6 +3104,22 @@ var D1Adapter = class {
|
|
|
2695
3104
|
).bind(...values).run();
|
|
2696
3105
|
return (res.meta.changes || 0) > 0;
|
|
2697
3106
|
}
|
|
3107
|
+
/**
|
|
3108
|
+
* 投递期间的租约续期(run-tick 的心跳用)。只在行仍是 pending 且确实持有
|
|
3109
|
+
* 租约(lease_until 非空)时生效——收尾把租约放掉之后,迟到的心跳不会把
|
|
3110
|
+
* 租约复活。
|
|
3111
|
+
*
|
|
3112
|
+
* @param {number} taskId
|
|
3113
|
+
* @param {string|Date} leaseUntil - 新的租期末尾
|
|
3114
|
+
* @returns {Promise<boolean>} true = 续上了
|
|
3115
|
+
*/
|
|
3116
|
+
async renewTaskLease(taskId, leaseUntil) {
|
|
3117
|
+
const res = await this._db.prepare(
|
|
3118
|
+
`UPDATE scheduled_messages SET lease_until = ?
|
|
3119
|
+
WHERE id = ? AND status = 'pending' AND lease_until IS NOT NULL`
|
|
3120
|
+
).bind(this._iso(leaseUntil), taskId).run();
|
|
3121
|
+
return (res.meta.changes || 0) > 0;
|
|
3122
|
+
}
|
|
2698
3123
|
async listTasks(userId, opts = {}) {
|
|
2699
3124
|
const { status = "all", limit = 20, offset = 0 } = opts;
|
|
2700
3125
|
const conditions = ["user_id = ?"];
|
|
@@ -2709,7 +3134,7 @@ var D1Adapter = class {
|
|
|
2709
3134
|
).bind(...params).first();
|
|
2710
3135
|
const total = Number(countRow.count) || 0;
|
|
2711
3136
|
const res = await this._db.prepare(
|
|
2712
|
-
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at
|
|
3137
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, last_error, created_at, updated_at
|
|
2713
3138
|
FROM scheduled_messages
|
|
2714
3139
|
WHERE ${where}
|
|
2715
3140
|
ORDER BY next_send_at ASC
|
|
@@ -2732,6 +3157,20 @@ var D1Adapter = class {
|
|
|
2732
3157
|
).bind(uuid, userId).first();
|
|
2733
3158
|
return row ? row.status : null;
|
|
2734
3159
|
}
|
|
3160
|
+
/**
|
|
3161
|
+
* 状态 + 失败摘要(GET /message 用它把「为什么失败」透给已失败的行——那些
|
|
3162
|
+
* 行 getTaskByUuid 读不到,payload 里的 lastError 也就够不着了)。
|
|
3163
|
+
*
|
|
3164
|
+
* @param {string} uuid
|
|
3165
|
+
* @param {string} userId
|
|
3166
|
+
* @returns {Promise<{ status: string, last_error: string|null }|null>}
|
|
3167
|
+
*/
|
|
3168
|
+
async getTaskStatusInfo(uuid, userId) {
|
|
3169
|
+
const row = await this._db.prepare(
|
|
3170
|
+
"SELECT status, last_error FROM scheduled_messages WHERE uuid = ? AND user_id = ? LIMIT 1"
|
|
3171
|
+
).bind(uuid, userId).first();
|
|
3172
|
+
return row ? { status: row.status, last_error: row.last_error ?? null } : null;
|
|
3173
|
+
}
|
|
2735
3174
|
// ── client_state (single-user cloud state mirror) ──────────────────────
|
|
2736
3175
|
/**
|
|
2737
3176
|
* Batch upsert. Last-write-wins per (namespace, key): an entry older
|
|
@@ -2862,6 +3301,127 @@ var D1Adapter = class {
|
|
|
2862
3301
|
const res = await this._db.prepare("DELETE FROM push_subscriptions WHERE user_id = ?").bind(userId).run();
|
|
2863
3302
|
return (res.meta.changes || 0) > 0;
|
|
2864
3303
|
}
|
|
3304
|
+
// ── message_outbox(服务端消息收件箱,客户端 ack)────────────────────────
|
|
3305
|
+
/**
|
|
3306
|
+
* 发送前把这一批 push 落进 outbox(一次 batch)。(user_id, message_id)
|
|
3307
|
+
* 唯一:重试同一 occurrence 带着同一批 messageId 再来时更新 payload、不加
|
|
3308
|
+
* 第二行;客户端已经 ack 过的行不动(ack 是终态,重试不该把它拉回未读)。
|
|
3309
|
+
*
|
|
3310
|
+
* @param {string} userId
|
|
3311
|
+
* @param {Array<{ message_id: string, task_uuid?: string|null, session_id?: string|null,
|
|
3312
|
+
* message_index?: number|null, total_messages?: number|null, payload: string, created_at: number }>} rows
|
|
3313
|
+
* `payload` 是整条 push JSON 的 encryptForStorage 密文。
|
|
3314
|
+
* @returns {Promise<number>} 实际写入/更新的行数
|
|
3315
|
+
*/
|
|
3316
|
+
async appendOutboxMessages(userId, rows) {
|
|
3317
|
+
if (!rows || rows.length === 0) return 0;
|
|
3318
|
+
const SQL = `INSERT INTO message_outbox
|
|
3319
|
+
(user_id, message_id, task_uuid, session_id, message_index, total_messages, payload, created_at)
|
|
3320
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3321
|
+
ON CONFLICT (user_id, message_id) DO UPDATE SET
|
|
3322
|
+
payload = excluded.payload,
|
|
3323
|
+
created_at = excluded.created_at,
|
|
3324
|
+
delivered_at = NULL
|
|
3325
|
+
WHERE message_outbox.acked_at IS NULL`;
|
|
3326
|
+
const statements = rows.map(
|
|
3327
|
+
(r) => this._db.prepare(SQL).bind(
|
|
3328
|
+
userId,
|
|
3329
|
+
r.message_id,
|
|
3330
|
+
r.task_uuid ?? null,
|
|
3331
|
+
r.session_id ?? null,
|
|
3332
|
+
r.message_index ?? null,
|
|
3333
|
+
r.total_messages ?? null,
|
|
3334
|
+
r.payload,
|
|
3335
|
+
r.created_at
|
|
3336
|
+
)
|
|
3337
|
+
);
|
|
3338
|
+
let results;
|
|
3339
|
+
if (typeof this._db.batch === "function") {
|
|
3340
|
+
results = await this._db.batch(statements);
|
|
3341
|
+
} else {
|
|
3342
|
+
results = [];
|
|
3343
|
+
for (const stmt of statements) results.push(await stmt.run());
|
|
3344
|
+
}
|
|
3345
|
+
return results.reduce((n, res) => n + ((res.meta.changes || 0) > 0 ? 1 : 0), 0);
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* 把这一批标记为「Web Push 已发出」。发送失败的段不标——delivered_at 为
|
|
3349
|
+
* null 的行正是客户端最需要拉的那部分。
|
|
3350
|
+
*
|
|
3351
|
+
* @param {string} userId
|
|
3352
|
+
* @param {string[]} messageIds
|
|
3353
|
+
* @param {number} deliveredAt - epoch 毫秒
|
|
3354
|
+
* @returns {Promise<number>}
|
|
3355
|
+
*/
|
|
3356
|
+
async markOutboxDelivered(userId, messageIds, deliveredAt) {
|
|
3357
|
+
if (!messageIds || messageIds.length === 0) return 0;
|
|
3358
|
+
const placeholders = messageIds.map(() => "?").join(", ");
|
|
3359
|
+
const res = await this._db.prepare(
|
|
3360
|
+
`UPDATE message_outbox SET delivered_at = ?
|
|
3361
|
+
WHERE user_id = ? AND message_id IN (${placeholders})`
|
|
3362
|
+
).bind(deliveredAt, userId, ...messageIds).run();
|
|
3363
|
+
return res.meta.changes || 0;
|
|
3364
|
+
}
|
|
3365
|
+
/**
|
|
3366
|
+
* 未 ack 的行(id 升序,游标翻页)。payload 仍是密文,解密在 handler。
|
|
3367
|
+
*
|
|
3368
|
+
* @param {string} userId
|
|
3369
|
+
* @param {number} sinceId - 上一页游标(0 = 从头)
|
|
3370
|
+
* @param {number} limit
|
|
3371
|
+
* @returns {Promise<Array<{ id: number, message_id: string, task_uuid: string|null,
|
|
3372
|
+
* session_id: string|null, message_index: number|null, total_messages: number|null,
|
|
3373
|
+
* payload: string, created_at: number, delivered_at: number|null }>>}
|
|
3374
|
+
*/
|
|
3375
|
+
async listUnackedOutbox(userId, sinceId = 0, limit = 50) {
|
|
3376
|
+
const res = await this._db.prepare(
|
|
3377
|
+
`SELECT id, message_id, task_uuid, session_id, message_index, total_messages, payload, created_at, delivered_at
|
|
3378
|
+
FROM message_outbox
|
|
3379
|
+
WHERE user_id = ? AND acked_at IS NULL AND id > ?
|
|
3380
|
+
ORDER BY id ASC
|
|
3381
|
+
LIMIT ?`
|
|
3382
|
+
).bind(userId, sinceId, limit).all();
|
|
3383
|
+
return res.results || [];
|
|
3384
|
+
}
|
|
3385
|
+
/**
|
|
3386
|
+
* 客户端确认收到这一批(幂等:已 ack 的行再 ack 不动)。
|
|
3387
|
+
*
|
|
3388
|
+
* @param {string} userId
|
|
3389
|
+
* @param {string[]} messageIds
|
|
3390
|
+
* @param {number} ackedAt - epoch 毫秒
|
|
3391
|
+
* @returns {Promise<number>} 本次真正被 ack 的行数
|
|
3392
|
+
*/
|
|
3393
|
+
async ackOutboxMessages(userId, messageIds, ackedAt) {
|
|
3394
|
+
if (!messageIds || messageIds.length === 0) return 0;
|
|
3395
|
+
const placeholders = messageIds.map(() => "?").join(", ");
|
|
3396
|
+
const res = await this._db.prepare(
|
|
3397
|
+
`UPDATE message_outbox SET acked_at = ?
|
|
3398
|
+
WHERE user_id = ? AND acked_at IS NULL AND message_id IN (${placeholders})`
|
|
3399
|
+
).bind(ackedAt, userId, ...messageIds).run();
|
|
3400
|
+
return res.meta.changes || 0;
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3403
|
+
* outbox 的例行清理(run-tick 每跳顺手调):已 ack 的行留短一些,未 ack 的
|
|
3404
|
+
* 也不无限留(Web Push TTL 上限四周,比它更老的推送谁也收不到了)。
|
|
3405
|
+
*
|
|
3406
|
+
* @param {{ ackedBeforeMs?: number, allBeforeMs?: number }} opts - epoch 毫秒阈值
|
|
3407
|
+
* @returns {Promise<number>} 删掉的行数
|
|
3408
|
+
*/
|
|
3409
|
+
async cleanupOutbox({ ackedBeforeMs, allBeforeMs } = {}) {
|
|
3410
|
+
let deleted = 0;
|
|
3411
|
+
if (Number.isFinite(ackedBeforeMs)) {
|
|
3412
|
+
const res = await this._db.prepare(
|
|
3413
|
+
"DELETE FROM message_outbox WHERE acked_at IS NOT NULL AND acked_at < ?"
|
|
3414
|
+
).bind(ackedBeforeMs).run();
|
|
3415
|
+
deleted += res.meta.changes || 0;
|
|
3416
|
+
}
|
|
3417
|
+
if (Number.isFinite(allBeforeMs)) {
|
|
3418
|
+
const res = await this._db.prepare(
|
|
3419
|
+
"DELETE FROM message_outbox WHERE created_at < ?"
|
|
3420
|
+
).bind(allBeforeMs).run();
|
|
3421
|
+
deleted += res.meta.changes || 0;
|
|
3422
|
+
}
|
|
3423
|
+
return deleted;
|
|
3424
|
+
}
|
|
2865
3425
|
};
|
|
2866
3426
|
function createD1Adapter(db) {
|
|
2867
3427
|
if (!db || typeof db.prepare !== "function") {
|
|
@@ -2981,12 +3541,6 @@ function err2(status, code, message, details) {
|
|
|
2981
3541
|
const error = details === void 0 ? { code, message } : { code, message, details };
|
|
2982
3542
|
return { status, body: { success: false, error } };
|
|
2983
3543
|
}
|
|
2984
|
-
function requireUserId2(headers) {
|
|
2985
|
-
const userId = getHeader(headers, "x-user-id");
|
|
2986
|
-
if (!userId) return { error: err2(400, "USER_ID_REQUIRED", "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26") };
|
|
2987
|
-
if (!isValidUUIDv4(userId)) return { error: err2(400, "INVALID_USER_ID_FORMAT", "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F") };
|
|
2988
|
-
return { userId };
|
|
2989
|
-
}
|
|
2990
3544
|
function rejectEntry(entry, index, code, message, extra) {
|
|
2991
3545
|
const rejection = { index, code, message, ...extra || {} };
|
|
2992
3546
|
if (entry && typeof entry === "object") {
|
|
@@ -3021,6 +3575,10 @@ function validateEntry(entry, index, maxValueBytes) {
|
|
|
3021
3575
|
if (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0) {
|
|
3022
3576
|
return rejectEntry(entry, index, "INVALID_STATE_UPDATED_AT", `entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
|
|
3023
3577
|
}
|
|
3578
|
+
const guard = entry.version ?? entry.builtAt;
|
|
3579
|
+
if (guard !== void 0 && (!Number.isInteger(guard) || guard <= 0)) {
|
|
3580
|
+
return rejectEntry(entry, index, "INVALID_STATE_VERSION", `entries[${index}].version / builtAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08\u6BEB\u79D2\u65F6\u95F4\u6233\u6216\u5355\u8C03\u9012\u589E\u7248\u672C\u53F7\uFF09`);
|
|
3581
|
+
}
|
|
3024
3582
|
return null;
|
|
3025
3583
|
}
|
|
3026
3584
|
function createClientStateHandler(ctx) {
|
|
@@ -3031,7 +3589,7 @@ function createClientStateHandler(ctx) {
|
|
|
3031
3589
|
if (getHeader(headers, "x-payload-encrypted") !== "true") {
|
|
3032
3590
|
return err2(400, "ENCRYPTION_REQUIRED", "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6");
|
|
3033
3591
|
}
|
|
3034
|
-
const gate =
|
|
3592
|
+
const gate = requireUserId(headers);
|
|
3035
3593
|
if (gate.error) return gate.error;
|
|
3036
3594
|
const { userId } = gate;
|
|
3037
3595
|
if (getHeader(headers, "x-encryption-version") !== "1") {
|
|
@@ -3062,14 +3620,20 @@ function createClientStateHandler(ctx) {
|
|
|
3062
3620
|
const rejected = [];
|
|
3063
3621
|
for (let i = 0; i < entries.length; i++) {
|
|
3064
3622
|
const rejection = validateEntry(entries[i], i, maxValueBytes);
|
|
3065
|
-
if (rejection)
|
|
3066
|
-
|
|
3623
|
+
if (rejection) {
|
|
3624
|
+
rejected.push(rejection);
|
|
3625
|
+
} else {
|
|
3626
|
+
const entry = entries[i];
|
|
3627
|
+
const guard = entry.version ?? entry.builtAt;
|
|
3628
|
+
accepted.push(guard !== void 0 ? { ...entry, version: guard } : entry);
|
|
3629
|
+
}
|
|
3067
3630
|
}
|
|
3068
3631
|
if (typeof db.upsertClientState !== "function") {
|
|
3069
3632
|
return err2(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
|
|
3070
3633
|
}
|
|
3071
|
-
const { upserted, skipped } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
|
|
3634
|
+
const { upserted, skipped, skippedEntries } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
|
|
3072
3635
|
const data = { upserted, skipped };
|
|
3636
|
+
if (skippedEntries.length > 0) data.skippedEntries = skippedEntries;
|
|
3073
3637
|
if (rejected.length > 0) data.rejected = rejected;
|
|
3074
3638
|
return { status: 200, body: { success: true, data } };
|
|
3075
3639
|
}
|
|
@@ -3077,7 +3641,7 @@ function createClientStateHandler(ctx) {
|
|
|
3077
3641
|
const tenantResult = await ctx.tenantManager.resolveTenant(headers);
|
|
3078
3642
|
if (!tenantResult.ok) return tenantResult.error;
|
|
3079
3643
|
const { db, masterKey } = tenantResult.context;
|
|
3080
|
-
const gate =
|
|
3644
|
+
const gate = requireUserId(headers);
|
|
3081
3645
|
if (gate.error) return gate.error;
|
|
3082
3646
|
const { userId } = gate;
|
|
3083
3647
|
const namespace = new URL(url, "https://dummy").searchParams.get("namespace") || "";
|
|
@@ -3102,7 +3666,7 @@ function createClientStateHandler(ctx) {
|
|
|
3102
3666
|
const tenantResult = await ctx.tenantManager.resolveTenant(headers);
|
|
3103
3667
|
if (!tenantResult.ok) return tenantResult.error;
|
|
3104
3668
|
const { db } = tenantResult.context;
|
|
3105
|
-
const gate =
|
|
3669
|
+
const gate = requireUserId(headers);
|
|
3106
3670
|
if (gate.error) return gate.error;
|
|
3107
3671
|
const { userId } = gate;
|
|
3108
3672
|
if (typeof db.clearClientState !== "function") {
|
|
@@ -3115,7 +3679,7 @@ function createClientStateHandler(ctx) {
|
|
|
3115
3679
|
}
|
|
3116
3680
|
|
|
3117
3681
|
// src/server/lib/version.js
|
|
3118
|
-
var SERVER_VERSION = true ? "2.6.0-next.
|
|
3682
|
+
var SERVER_VERSION = true ? "2.6.0-next.15" : "0.0.0-dev";
|
|
3119
3683
|
|
|
3120
3684
|
// src/server/handlers/capabilities.js
|
|
3121
3685
|
var SERVER_FEATURES = Object.freeze([
|
|
@@ -3158,7 +3722,33 @@ var SERVER_FEATURES = Object.freeze([
|
|
|
3158
3722
|
// runScheduledTick 认 serializeBy:同一分组的任务同时只跑一条,跨跳也算。
|
|
3159
3723
|
"tick-serialize-by",
|
|
3160
3724
|
// 一次 fire 无论什么结局都调 onFireSettled(发完 / 跳过 / 抛错)。
|
|
3161
|
-
"fire-settled-hook"
|
|
3725
|
+
"fire-settled-hook",
|
|
3726
|
+
// PUT /client-state 的 entry 认 version / builtAt 护栏(按内容新旧比较),
|
|
3727
|
+
// 被拦下的 key 在 data.skippedEntries 逐条回报。
|
|
3728
|
+
"client-state-version-guard",
|
|
3729
|
+
// POST /schedule-message 认 immediate: true(不排未来,下一跳 cron 直接触发)。
|
|
3730
|
+
"schedule-immediate",
|
|
3731
|
+
// POST /schedule-message 认 supersedesUuid(建新任务的同一事务里取消旧的)。
|
|
3732
|
+
"schedule-supersede",
|
|
3733
|
+
// 投递期间租约按心跳滚动续租,isolate 死亡后任务 ~90s 内被下一跳接手。
|
|
3734
|
+
"tick-lease-heartbeat",
|
|
3735
|
+
// 导出 runTask(ctx, uuid):单任务投递入口(CF Queue 消费者用)。
|
|
3736
|
+
"run-task-entrypoint",
|
|
3737
|
+
// 任务行有 last_error 列(脱敏失败摘要);GET /message 对已失败的行也透出
|
|
3738
|
+
// (409 的 error.details.lastError)。
|
|
3739
|
+
"task-last-error",
|
|
3740
|
+
// 导出 NonRetryableError:hook 抛它 → 直接终审处置,不进重试阶梯。
|
|
3741
|
+
"non-retryable-error",
|
|
3742
|
+
// fire ctx(fireCtx / sessionCtx)带 cancelTask / renewTask。
|
|
3743
|
+
"agentic-cancel-renew-task",
|
|
3744
|
+
// 服务端消息收件箱:push 发送前落 message_outbox,GET /outbox + POST
|
|
3745
|
+
// /outbox/ack 取代「猜哪些没收到」的补收对账。
|
|
3746
|
+
"message-outbox",
|
|
3747
|
+
// onFireSettled 载荷带解密 metadata 与最后一轮 usage;sessionCtx 带 usage。
|
|
3748
|
+
"fire-settled-metadata",
|
|
3749
|
+
"hook-usage",
|
|
3750
|
+
// schedule-message 认 llmExtraBody(原样展开进 LLM 请求体,核心字段优先)。
|
|
3751
|
+
"llm-extra-body"
|
|
3162
3752
|
]);
|
|
3163
3753
|
function createCapabilitiesHandler(ctx) {
|
|
3164
3754
|
async function GET(url, headers) {
|
|
@@ -3175,6 +3765,116 @@ function createCapabilitiesHandler(ctx) {
|
|
|
3175
3765
|
return { GET };
|
|
3176
3766
|
}
|
|
3177
3767
|
|
|
3768
|
+
// src/server/handlers/outbox.js
|
|
3769
|
+
var MAX_OUTBOX_PAGE_SIZE = 100;
|
|
3770
|
+
var DEFAULT_OUTBOX_PAGE_SIZE = 50;
|
|
3771
|
+
var MAX_OUTBOX_ACK_IDS = 200;
|
|
3772
|
+
function err3(status, code, message, details) {
|
|
3773
|
+
const error = details === void 0 ? { code, message } : { code, message, details };
|
|
3774
|
+
return { status, body: { success: false, error } };
|
|
3775
|
+
}
|
|
3776
|
+
function createOutboxHandler(ctx) {
|
|
3777
|
+
async function GET(url, headers) {
|
|
3778
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers);
|
|
3779
|
+
if (!tenantResult.ok) return tenantResult.error;
|
|
3780
|
+
const { db, masterKey } = tenantResult.context;
|
|
3781
|
+
const gate = requireUserId(headers);
|
|
3782
|
+
if (gate.error) return gate.error;
|
|
3783
|
+
const { userId } = gate;
|
|
3784
|
+
if (typeof db.listUnackedOutbox !== "function") {
|
|
3785
|
+
return err3(501, "OUTBOX_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 message_outbox");
|
|
3786
|
+
}
|
|
3787
|
+
const params = new URL(url, "https://dummy").searchParams;
|
|
3788
|
+
const sinceRaw = params.get("since");
|
|
3789
|
+
const since = sinceRaw == null || sinceRaw === "" ? 0 : Number(sinceRaw);
|
|
3790
|
+
if (!Number.isInteger(since) || since < 0) {
|
|
3791
|
+
return err3(400, "INVALID_OUTBOX_CURSOR", "since \u5FC5\u987B\u662F\u975E\u8D1F\u6574\u6570\uFF08\u4E0A\u4E00\u9875\u54CD\u5E94\u91CC\u7684 cursor\uFF09");
|
|
3792
|
+
}
|
|
3793
|
+
const limitRaw = params.get("limit");
|
|
3794
|
+
let limit = limitRaw == null || limitRaw === "" ? DEFAULT_OUTBOX_PAGE_SIZE : Number(limitRaw);
|
|
3795
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
3796
|
+
return err3(400, "INVALID_OUTBOX_LIMIT", `limit \u5FC5\u987B\u662F 1-${MAX_OUTBOX_PAGE_SIZE} \u7684\u6574\u6570`);
|
|
3797
|
+
}
|
|
3798
|
+
limit = Math.min(limit, MAX_OUTBOX_PAGE_SIZE);
|
|
3799
|
+
const userKey = await deriveUserEncryptionKey(userId, masterKey);
|
|
3800
|
+
const rows = await db.listUnackedOutbox(userId, since, limit);
|
|
3801
|
+
const entries = [];
|
|
3802
|
+
let cursor = since;
|
|
3803
|
+
for (const row of rows) {
|
|
3804
|
+
cursor = Math.max(cursor, row.id);
|
|
3805
|
+
let push;
|
|
3806
|
+
try {
|
|
3807
|
+
push = JSON.parse(await decryptFromStorage(row.payload, userKey));
|
|
3808
|
+
} catch (error) {
|
|
3809
|
+
console.warn("[amsg-server] outbox \u884C\u89E3\u5BC6\u5931\u8D25\uFF08\u5DF2\u8DF3\u8FC7\uFF09:", row.id, error && error.message);
|
|
3810
|
+
continue;
|
|
3811
|
+
}
|
|
3812
|
+
entries.push({
|
|
3813
|
+
id: row.id,
|
|
3814
|
+
messageId: row.message_id,
|
|
3815
|
+
taskUuid: row.task_uuid ?? null,
|
|
3816
|
+
sessionId: row.session_id ?? null,
|
|
3817
|
+
messageIndex: row.message_index ?? null,
|
|
3818
|
+
totalMessages: row.total_messages ?? null,
|
|
3819
|
+
createdAt: row.created_at,
|
|
3820
|
+
deliveredAt: row.delivered_at ?? null,
|
|
3821
|
+
push
|
|
3822
|
+
});
|
|
3823
|
+
}
|
|
3824
|
+
const data = {
|
|
3825
|
+
entries,
|
|
3826
|
+
cursor,
|
|
3827
|
+
// 按「捞满一页」判断还有没有下一页;解密失败被跳过的行不影响游标推进。
|
|
3828
|
+
hasMore: rows.length === limit
|
|
3829
|
+
};
|
|
3830
|
+
const encryptedResponse = await encryptPayload(data, userKey);
|
|
3831
|
+
return { status: 200, body: { success: true, encrypted: true, version: 1, data: encryptedResponse } };
|
|
3832
|
+
}
|
|
3833
|
+
async function POST(headers, body) {
|
|
3834
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers);
|
|
3835
|
+
if (!tenantResult.ok) return tenantResult.error;
|
|
3836
|
+
const { db, masterKey } = tenantResult.context;
|
|
3837
|
+
if (getHeader(headers, "x-payload-encrypted") !== "true") {
|
|
3838
|
+
return err3(400, "ENCRYPTION_REQUIRED", "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6");
|
|
3839
|
+
}
|
|
3840
|
+
const gate = requireUserId(headers);
|
|
3841
|
+
if (gate.error) return gate.error;
|
|
3842
|
+
const { userId } = gate;
|
|
3843
|
+
if (getHeader(headers, "x-encryption-version") !== "1") {
|
|
3844
|
+
return err3(400, "UNSUPPORTED_ENCRYPTION_VERSION", "\u52A0\u5BC6\u7248\u672C\u4E0D\u652F\u6301");
|
|
3845
|
+
}
|
|
3846
|
+
const parsedBody = parseEncryptedBody(body);
|
|
3847
|
+
if (!parsedBody.ok) return { status: 400, body: { success: false, error: parsedBody.error } };
|
|
3848
|
+
const userKey = await deriveUserEncryptionKey(userId, masterKey);
|
|
3849
|
+
let payload;
|
|
3850
|
+
try {
|
|
3851
|
+
payload = await decryptPayload(parsedBody.data, userKey);
|
|
3852
|
+
} catch (error) {
|
|
3853
|
+
if (error instanceof SyntaxError) {
|
|
3854
|
+
return err3(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u4E0D\u662F\u6709\u6548 JSON");
|
|
3855
|
+
}
|
|
3856
|
+
return err3(400, "DECRYPTION_FAILED", "\u8BF7\u6C42\u4F53\u89E3\u5BC6\u5931\u8D25");
|
|
3857
|
+
}
|
|
3858
|
+
if (!isPlainObject(payload)) return err3(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u5FC5\u987B\u662F JSON \u5BF9\u8C61");
|
|
3859
|
+
const messageIds = payload.messageIds;
|
|
3860
|
+
if (!Array.isArray(messageIds) || messageIds.length === 0) {
|
|
3861
|
+
return err3(400, "INVALID_OUTBOX_ACK", "messageIds \u5FC5\u987B\u662F\u975E\u7A7A\u6570\u7EC4");
|
|
3862
|
+
}
|
|
3863
|
+
if (messageIds.length > MAX_OUTBOX_ACK_IDS) {
|
|
3864
|
+
return err3(400, "TOO_MANY_OUTBOX_ACK_IDS", `\u5355\u6B21\u6700\u591A ack ${MAX_OUTBOX_ACK_IDS} \u6761`, { count: messageIds.length });
|
|
3865
|
+
}
|
|
3866
|
+
if (!messageIds.every((id) => typeof id === "string" && id.trim())) {
|
|
3867
|
+
return err3(400, "INVALID_OUTBOX_ACK", "messageIds \u7684\u6BCF\u4E00\u9879\u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32");
|
|
3868
|
+
}
|
|
3869
|
+
if (typeof db.ackOutboxMessages !== "function") {
|
|
3870
|
+
return err3(501, "OUTBOX_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 message_outbox");
|
|
3871
|
+
}
|
|
3872
|
+
const acked = await db.ackOutboxMessages(userId, messageIds, Date.now());
|
|
3873
|
+
return { status: 200, body: { success: true, data: { acked } } };
|
|
3874
|
+
}
|
|
3875
|
+
return { GET, POST };
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3178
3878
|
// src/server/single-user.js
|
|
3179
3879
|
function createSingleUserServer(config) {
|
|
3180
3880
|
if (!config || !config.db) throw new Error("[amsg-server single-user] config.db is required");
|
|
@@ -3221,63 +3921,8 @@ function createSingleUserServer(config) {
|
|
|
3221
3921
|
vapidPublicKey: createVapidPublicKeyHandler(ctx),
|
|
3222
3922
|
clientState: createClientStateHandler(ctx),
|
|
3223
3923
|
pushSubscription: createPushSubscriptionHandler(ctx),
|
|
3224
|
-
capabilities: createCapabilitiesHandler(ctx)
|
|
3225
|
-
|
|
3226
|
-
};
|
|
3227
|
-
}
|
|
3228
|
-
|
|
3229
|
-
// src/server/lib/webpush-webcrypto.js
|
|
3230
|
-
import { sendWebPush as sharedSendWebPush } from "@rei-standard/amsg-shared";
|
|
3231
|
-
import { buildVapidJwt, verifyVapidJwt, hmacSha256 as hmacSha2562 } from "@rei-standard/amsg-shared";
|
|
3232
|
-
var WEB_PUSH_MAX_BODY_BYTES = 4096;
|
|
3233
|
-
var WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES = 16 + 4 + 1 + 65 + 1 + 16;
|
|
3234
|
-
var MAX_PUSH_PAYLOAD_BYTES = WEB_PUSH_MAX_BODY_BYTES - WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES;
|
|
3235
|
-
var PUSH_ENVELOPE_RESERVED_BYTES = 384;
|
|
3236
|
-
var payloadEncoder = new TextEncoder();
|
|
3237
|
-
function measurePushPayload(payload, options) {
|
|
3238
|
-
const reserveEnvelope = !!(options && options.reserveEnvelope);
|
|
3239
|
-
const bytes = payloadEncoder.encode(typeof payload === "string" ? payload : String(payload)).length;
|
|
3240
|
-
const envelopeReservedBytes = reserveEnvelope ? PUSH_ENVELOPE_RESERVED_BYTES : 0;
|
|
3241
|
-
const maxBytes = MAX_PUSH_PAYLOAD_BYTES - envelopeReservedBytes;
|
|
3242
|
-
return {
|
|
3243
|
-
bytes,
|
|
3244
|
-
maxBytes,
|
|
3245
|
-
remainingBytes: maxBytes - bytes,
|
|
3246
|
-
withinLimit: bytes <= maxBytes,
|
|
3247
|
-
envelopeReservedBytes
|
|
3248
|
-
};
|
|
3249
|
-
}
|
|
3250
|
-
async function sendWebPush(args) {
|
|
3251
|
-
const { payload } = args || {};
|
|
3252
|
-
if (typeof payload === "string") {
|
|
3253
|
-
const size = measurePushPayload(payload);
|
|
3254
|
-
if (!size.withinLimit) {
|
|
3255
|
-
const err3 = new Error(
|
|
3256
|
-
`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)`
|
|
3257
|
-
);
|
|
3258
|
-
err3.code = "PUSH_PAYLOAD_TOO_LARGE";
|
|
3259
|
-
err3.bytes = size.bytes;
|
|
3260
|
-
err3.maxBytes = MAX_PUSH_PAYLOAD_BYTES;
|
|
3261
|
-
throw err3;
|
|
3262
|
-
}
|
|
3263
|
-
}
|
|
3264
|
-
return sharedSendWebPush(args);
|
|
3265
|
-
}
|
|
3266
|
-
var SCHEDULED_DEFAULT_TTL = 2419200;
|
|
3267
|
-
function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}) {
|
|
3268
|
-
return {
|
|
3269
|
-
async sendNotification(subscription, payload) {
|
|
3270
|
-
return sendWebPush({
|
|
3271
|
-
subscription,
|
|
3272
|
-
payload,
|
|
3273
|
-
ttl,
|
|
3274
|
-
vapid: {
|
|
3275
|
-
email: vapid.email,
|
|
3276
|
-
publicKey: vapid.publicKey,
|
|
3277
|
-
privateKey: vapid.privateKey
|
|
3278
|
-
},
|
|
3279
|
-
fetch: globalThis.fetch
|
|
3280
|
-
});
|
|
3924
|
+
capabilities: createCapabilitiesHandler(ctx),
|
|
3925
|
+
outbox: createOutboxHandler(ctx)
|
|
3281
3926
|
}
|
|
3282
3927
|
};
|
|
3283
3928
|
}
|
|
@@ -3298,7 +3943,17 @@ var CORS_ALLOW_HEADERS = "Content-Type, X-User-Id, X-Payload-Encrypted, X-Encryp
|
|
|
3298
3943
|
var CORS_ALLOW_METHODS = "GET, POST, PUT, DELETE, OPTIONS";
|
|
3299
3944
|
function corsHeadersFor(cors, requestOrigin) {
|
|
3300
3945
|
if (!cors || cors.origin == null) return null;
|
|
3301
|
-
|
|
3946
|
+
let allowOrigin;
|
|
3947
|
+
if (typeof cors.origin === "function") {
|
|
3948
|
+
try {
|
|
3949
|
+
allowOrigin = cors.origin(requestOrigin) || null;
|
|
3950
|
+
} catch (error) {
|
|
3951
|
+
console.warn("[amsg single-user] cors.origin \u56DE\u8C03\u629B\u9519\uFF0C\u6309\u4E0D\u653E\u884C\u5904\u7406:", error && error.message);
|
|
3952
|
+
allowOrigin = null;
|
|
3953
|
+
}
|
|
3954
|
+
} else {
|
|
3955
|
+
allowOrigin = cors.origin;
|
|
3956
|
+
}
|
|
3302
3957
|
if (!allowOrigin) return null;
|
|
3303
3958
|
const headers = {
|
|
3304
3959
|
"Access-Control-Allow-Origin": allowOrigin,
|
|
@@ -3367,6 +4022,10 @@ function createSingleUserCloudflareWorker(buildConfig) {
|
|
|
3367
4022
|
result = await server.handlers.clientState.GET(url, headers);
|
|
3368
4023
|
} else if (method === "DELETE" && pathname.endsWith("/client-state")) {
|
|
3369
4024
|
result = await server.handlers.clientState.DELETE(url, headers);
|
|
4025
|
+
} else if (method === "GET" && pathname.endsWith("/outbox")) {
|
|
4026
|
+
result = await server.handlers.outbox.GET(url, headers);
|
|
4027
|
+
} else if (method === "POST" && pathname.endsWith("/outbox/ack")) {
|
|
4028
|
+
result = await server.handlers.outbox.POST(headers, await request.text());
|
|
3370
4029
|
} else if (method === "PUT" && pathname.endsWith("/push-subscription")) {
|
|
3371
4030
|
result = await server.handlers.pushSubscription.PUT(headers, await request.text());
|
|
3372
4031
|
} else if (method === "GET" && pathname.endsWith("/push-subscription")) {
|
|
@@ -3383,7 +4042,13 @@ function createSingleUserCloudflareWorker(buildConfig) {
|
|
|
3383
4042
|
}
|
|
3384
4043
|
}
|
|
3385
4044
|
async function scheduled(event, env) {
|
|
3386
|
-
|
|
4045
|
+
let cfg;
|
|
4046
|
+
try {
|
|
4047
|
+
cfg = await resolveConfig(env);
|
|
4048
|
+
} catch (error) {
|
|
4049
|
+
console.error("[amsg single-user] scheduled(): config build failed; skipping tick:", error && error.message);
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
3387
4052
|
const vapid = cfg.vapid || {};
|
|
3388
4053
|
if (!cfg.webpush || !vapid.email || !vapid.publicKey || !vapid.privateKey) {
|
|
3389
4054
|
console.error("[amsg single-user] scheduled(): VAPID/webpush not configured; skipping tick");
|
|
@@ -3421,8 +4086,12 @@ function createSingleUserCloudflareWorker(buildConfig) {
|
|
|
3421
4086
|
// 分组串行:(task) => 分组标识 | null。同一分组的任务同时只跑一条,
|
|
3422
4087
|
// 跨跳也算(见 lib/run-tick.js)。不配 = 全并发,与以前一致。
|
|
3423
4088
|
serializeBy: cfg.serializeBy,
|
|
3424
|
-
//
|
|
3425
|
-
claimLeaseMs: cfg.claimLeaseMs
|
|
4089
|
+
// 任务占位租期(只在心跳关掉时生效;默认 10 分钟,随 totalTimeoutMs 抬高)。
|
|
4090
|
+
claimLeaseMs: cfg.claimLeaseMs,
|
|
4091
|
+
// 租约心跳间隔(默认 30s;0 = 关掉心跳,退回一次性长租约)。
|
|
4092
|
+
leaseHeartbeatMs: cfg.leaseHeartbeatMs,
|
|
4093
|
+
// 补发新鲜度阈值(默认 60 分钟;见 lib/run-tick.js 的 STALE_AFTER_MS)。
|
|
4094
|
+
staleAfterMs: cfg.staleAfterMs
|
|
3426
4095
|
});
|
|
3427
4096
|
} catch (error) {
|
|
3428
4097
|
console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);
|
|
@@ -3431,14 +4100,70 @@ function createSingleUserCloudflareWorker(buildConfig) {
|
|
|
3431
4100
|
return { fetch, scheduled };
|
|
3432
4101
|
}
|
|
3433
4102
|
|
|
4103
|
+
// src/server/lib/webpush-webcrypto.js
|
|
4104
|
+
import { sendWebPush as sharedSendWebPush } from "@rei-standard/amsg-shared";
|
|
4105
|
+
import { buildVapidJwt, verifyVapidJwt, hmacSha256 as hmacSha2562 } from "@rei-standard/amsg-shared";
|
|
4106
|
+
var WEB_PUSH_MAX_BODY_BYTES = 4096;
|
|
4107
|
+
var WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES = 16 + 4 + 1 + 65 + 1 + 16;
|
|
4108
|
+
var MAX_PUSH_PAYLOAD_BYTES = WEB_PUSH_MAX_BODY_BYTES - WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES;
|
|
4109
|
+
var PUSH_ENVELOPE_RESERVED_BYTES = 384;
|
|
4110
|
+
var payloadEncoder = new TextEncoder();
|
|
4111
|
+
function measurePushPayload(payload, options) {
|
|
4112
|
+
const reserveEnvelope = !!(options && options.reserveEnvelope);
|
|
4113
|
+
const bytes = payloadEncoder.encode(typeof payload === "string" ? payload : String(payload)).length;
|
|
4114
|
+
const envelopeReservedBytes = reserveEnvelope ? PUSH_ENVELOPE_RESERVED_BYTES : 0;
|
|
4115
|
+
const maxBytes = MAX_PUSH_PAYLOAD_BYTES - envelopeReservedBytes;
|
|
4116
|
+
return {
|
|
4117
|
+
bytes,
|
|
4118
|
+
maxBytes,
|
|
4119
|
+
remainingBytes: maxBytes - bytes,
|
|
4120
|
+
withinLimit: bytes <= maxBytes,
|
|
4121
|
+
envelopeReservedBytes
|
|
4122
|
+
};
|
|
4123
|
+
}
|
|
4124
|
+
async function sendWebPush(args) {
|
|
4125
|
+
const { payload } = args || {};
|
|
4126
|
+
if (typeof payload === "string") {
|
|
4127
|
+
const size = measurePushPayload(payload);
|
|
4128
|
+
if (!size.withinLimit) {
|
|
4129
|
+
const err4 = new Error(
|
|
4130
|
+
`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)`
|
|
4131
|
+
);
|
|
4132
|
+
err4.code = "PUSH_PAYLOAD_TOO_LARGE";
|
|
4133
|
+
err4.bytes = size.bytes;
|
|
4134
|
+
err4.maxBytes = MAX_PUSH_PAYLOAD_BYTES;
|
|
4135
|
+
throw err4;
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4138
|
+
return sharedSendWebPush(args);
|
|
4139
|
+
}
|
|
4140
|
+
var SCHEDULED_DEFAULT_TTL = 2419200;
|
|
4141
|
+
function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}) {
|
|
4142
|
+
return {
|
|
4143
|
+
async sendNotification(subscription, payload) {
|
|
4144
|
+
return sendWebPush({
|
|
4145
|
+
subscription,
|
|
4146
|
+
payload,
|
|
4147
|
+
ttl,
|
|
4148
|
+
vapid: {
|
|
4149
|
+
email: vapid.email,
|
|
4150
|
+
publicKey: vapid.publicKey,
|
|
4151
|
+
privateKey: vapid.privateKey
|
|
4152
|
+
},
|
|
4153
|
+
fetch: globalThis.fetch
|
|
4154
|
+
});
|
|
4155
|
+
}
|
|
4156
|
+
};
|
|
4157
|
+
}
|
|
4158
|
+
|
|
3434
4159
|
export {
|
|
3435
4160
|
isValidTimeZoneId,
|
|
3436
4161
|
advanceOccurrence,
|
|
3437
4162
|
planNextOccurrence,
|
|
3438
4163
|
nextFutureOccurrence,
|
|
4164
|
+
isValidUrl,
|
|
3439
4165
|
validateAvatarUrl,
|
|
3440
4166
|
isValidISO8601,
|
|
3441
|
-
isValidUrl,
|
|
3442
4167
|
isValidUUID,
|
|
3443
4168
|
isValidUUIDv4,
|
|
3444
4169
|
validateSplitPattern,
|
|
@@ -3456,8 +4181,19 @@ export {
|
|
|
3456
4181
|
encryptForStorage,
|
|
3457
4182
|
decryptFromStorage,
|
|
3458
4183
|
createGetUserKeyHandler,
|
|
4184
|
+
DEFAULT_MAX_TOOL_ITERATIONS,
|
|
4185
|
+
DEFAULT_TOTAL_TIMEOUT_MS,
|
|
4186
|
+
DEFAULT_MAX_SCHEDULED_TASKS_PER_FIRE,
|
|
4187
|
+
MIN_SCHEDULE_LEAD_MS,
|
|
4188
|
+
NonRetryableError,
|
|
4189
|
+
isNonRetryableError,
|
|
4190
|
+
sanitizeErrorSummary,
|
|
3459
4191
|
createScheduleMessageHandler,
|
|
4192
|
+
DEFAULT_CLAIM_LEASE_MS,
|
|
4193
|
+
DEFAULT_LEASE_HEARTBEAT_MS,
|
|
4194
|
+
DEFAULT_HEARTBEAT_LEASE_TTL_MS,
|
|
3460
4195
|
runScheduledTick,
|
|
4196
|
+
runTask,
|
|
3461
4197
|
createUpdateMessageHandler,
|
|
3462
4198
|
createCancelMessageHandler,
|
|
3463
4199
|
createMessagesHandler,
|
|
@@ -3465,11 +4201,13 @@ export {
|
|
|
3465
4201
|
createPushSubscriptionHandler,
|
|
3466
4202
|
createD1Adapter,
|
|
3467
4203
|
createSingleUserServer,
|
|
4204
|
+
CORS_ALLOW_HEADERS,
|
|
4205
|
+
CORS_ALLOW_METHODS,
|
|
4206
|
+
createSingleUserCloudflareWorker,
|
|
3468
4207
|
WEB_PUSH_MAX_BODY_BYTES,
|
|
3469
4208
|
WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES,
|
|
3470
4209
|
MAX_PUSH_PAYLOAD_BYTES,
|
|
3471
4210
|
PUSH_ENVELOPE_RESERVED_BYTES,
|
|
3472
4211
|
measurePushPayload,
|
|
3473
|
-
createWebCryptoWebPush
|
|
3474
|
-
createSingleUserCloudflareWorker
|
|
4212
|
+
createWebCryptoWebPush
|
|
3475
4213
|
};
|