@easy-cms/core 0.6.0 → 0.7.0
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/index.d.ts +26 -3
- package/dist/index.js +190 -35
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -855,8 +855,9 @@ interface CreateEasyCMSOptions {
|
|
|
855
855
|
/** Project root. Default `process.cwd()`. */
|
|
856
856
|
readonly cwd?: string;
|
|
857
857
|
/**
|
|
858
|
-
* Run due scheduled jobs every minute in this process. Default: on when
|
|
859
|
-
* global has `schedule
|
|
858
|
+
* Run due scheduled jobs and webhook retries every minute in this process. Default: on when
|
|
859
|
+
* a collection or global has `schedule`, or `webhooks` is set. Turn it off where a cron calls
|
|
860
|
+
* `runJobs` (or `GET <api>/jobs/run`) instead.
|
|
860
861
|
*/
|
|
861
862
|
readonly scheduler?: boolean;
|
|
862
863
|
/** Default `verify` when `NODE_ENV=production`, otherwise `push`. */
|
|
@@ -992,8 +993,30 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
992
993
|
destroy(): Promise<void>;
|
|
993
994
|
private schedulerTimer;
|
|
994
995
|
private running;
|
|
995
|
-
/** Runs
|
|
996
|
+
/** Runs `runJobs` every `interval` ms in this process (`createEasyCMS` does this by default). */
|
|
996
997
|
startScheduler(interval?: number): void;
|
|
998
|
+
/**
|
|
999
|
+
* Runs due scheduled publishes and unpublishes, and retries failed webhook deliveries. What the
|
|
1000
|
+
* scheduler and the cron endpoint (`GET <api>/jobs/run`) call.
|
|
1001
|
+
*/
|
|
1002
|
+
runJobs(now?: Date): Promise<{
|
|
1003
|
+
ran: number;
|
|
1004
|
+
failed: number;
|
|
1005
|
+
webhooks: {
|
|
1006
|
+
sent: number;
|
|
1007
|
+
failed: number;
|
|
1008
|
+
};
|
|
1009
|
+
}>;
|
|
1010
|
+
/**
|
|
1011
|
+
* Tries webhook deliveries that failed earlier and are due for another attempt. Failed
|
|
1012
|
+
* deliveries are kept in the database, so they survive restarts.
|
|
1013
|
+
*/
|
|
1014
|
+
retryWebhooks(now?: Date): Promise<{
|
|
1015
|
+
sent: number;
|
|
1016
|
+
failed: number;
|
|
1017
|
+
}>;
|
|
1018
|
+
/** Failed webhook deliveries, stored in `webhook-deliveries`. */
|
|
1019
|
+
private webhookQueue;
|
|
997
1020
|
/**
|
|
998
1021
|
* Publishes or unpublishes documents whose scheduled time has come. Safe to call often, e.g.
|
|
999
1022
|
* from a cron job (`GET <api>/jobs/run`) where no process keeps running.
|
package/dist/index.js
CHANGED
|
@@ -189,11 +189,13 @@ var SESSIONS = "sessions";
|
|
|
189
189
|
var LOGIN_ATTEMPTS = "login-attempts";
|
|
190
190
|
var VERSIONS = "document-versions";
|
|
191
191
|
var SCHEDULED_JOBS = "scheduled-jobs";
|
|
192
|
+
var WEBHOOK_DELIVERIES = "webhook-deliveries";
|
|
192
193
|
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
193
194
|
SESSIONS,
|
|
194
195
|
LOGIN_ATTEMPTS,
|
|
195
196
|
VERSIONS,
|
|
196
|
-
SCHEDULED_JOBS
|
|
197
|
+
SCHEDULED_JOBS,
|
|
198
|
+
WEBHOOK_DELIVERIES
|
|
197
199
|
]);
|
|
198
200
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
199
201
|
var adminOrSelf = ({ user }) => {
|
|
@@ -390,6 +392,22 @@ var scheduledJobsCollection = {
|
|
|
390
392
|
{ name: "author", type: "number" }
|
|
391
393
|
]
|
|
392
394
|
};
|
|
395
|
+
var webhookDeliveriesCollection = {
|
|
396
|
+
slug: WEBHOOK_DELIVERIES,
|
|
397
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
398
|
+
fields: [
|
|
399
|
+
{ name: "url", type: "text", required: true },
|
|
400
|
+
{ name: "event", type: "text", required: true },
|
|
401
|
+
// The JSON body as first sent: retries are byte-for-byte the same, signature included.
|
|
402
|
+
{ name: "body", type: "textarea", required: true },
|
|
403
|
+
{ name: "delivery", type: "text", required: true },
|
|
404
|
+
{ name: "attempts", type: "number", required: true },
|
|
405
|
+
{ name: "nextAttemptAt", type: "text", required: true, index: true },
|
|
406
|
+
// pending | failed
|
|
407
|
+
{ name: "state", type: "text", required: true, index: true },
|
|
408
|
+
{ name: "error", type: "text" }
|
|
409
|
+
]
|
|
410
|
+
};
|
|
393
411
|
|
|
394
412
|
// src/auth/password.ts
|
|
395
413
|
import { randomBytes, scrypt, timingSafeEqual } from "crypto";
|
|
@@ -1009,16 +1027,20 @@ var WEBHOOK_EVENTS = [
|
|
|
1009
1027
|
"draft"
|
|
1010
1028
|
];
|
|
1011
1029
|
var RETRY_DELAYS = [1e3, 5e3];
|
|
1030
|
+
var QUEUE_DELAYS = [6e4, 5 * 6e4, 30 * 6e4, 2 * 36e5, 6 * 36e5, 12 * 36e5];
|
|
1031
|
+
var CLAIM = 5 * 6e4;
|
|
1012
1032
|
var TIMEOUT = 1e4;
|
|
1013
1033
|
var Webhooks = class {
|
|
1014
|
-
constructor(hooks, logger, fetcher = (...args) => fetch(...args), retryDelays = RETRY_DELAYS) {
|
|
1034
|
+
constructor(hooks, logger, queue, fetcher = (...args) => fetch(...args), retryDelays = RETRY_DELAYS) {
|
|
1015
1035
|
this.hooks = hooks;
|
|
1016
1036
|
this.logger = logger;
|
|
1037
|
+
this.queue = queue;
|
|
1017
1038
|
this.fetcher = fetcher;
|
|
1018
1039
|
this.retryDelays = retryDelays;
|
|
1019
1040
|
}
|
|
1020
1041
|
hooks;
|
|
1021
1042
|
logger;
|
|
1043
|
+
queue;
|
|
1022
1044
|
fetcher;
|
|
1023
1045
|
retryDelays;
|
|
1024
1046
|
pending = /* @__PURE__ */ new Set();
|
|
@@ -1027,53 +1049,127 @@ var Webhooks = class {
|
|
|
1027
1049
|
const payload = { event, ...target, doc, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1028
1050
|
for (const hook of this.hooks) {
|
|
1029
1051
|
if (!matches(hook, payload)) continue;
|
|
1030
|
-
|
|
1031
|
-
this.pending.add(delivery);
|
|
1052
|
+
this.track(this.deliver(hook, payload));
|
|
1032
1053
|
}
|
|
1033
1054
|
}
|
|
1034
1055
|
/** Waits for deliveries in progress, e.g. before a serverless function returns. */
|
|
1035
1056
|
async flush() {
|
|
1036
1057
|
while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
|
|
1037
1058
|
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Tries queued deliveries whose next attempt is due. Runs with scheduled jobs: every minute
|
|
1061
|
+
* in a server, or from the cron endpoint.
|
|
1062
|
+
*/
|
|
1063
|
+
async retry(now = /* @__PURE__ */ new Date()) {
|
|
1064
|
+
if (!this.queue) return { sent: 0, failed: 0 };
|
|
1065
|
+
let sent = 0;
|
|
1066
|
+
let failed = 0;
|
|
1067
|
+
for (const entry of await this.queue.due(now, 50)) {
|
|
1068
|
+
const { id, ...queued } = entry;
|
|
1069
|
+
const hook = this.hooks.find((h) => h.url === queued.url);
|
|
1070
|
+
if (!hook) {
|
|
1071
|
+
await this.queue.remove(id);
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
const attempts = queued.attempts + 1;
|
|
1075
|
+
await this.queue.update(id, { ...queued, attempts, nextAttemptAt: later(now, CLAIM) });
|
|
1076
|
+
const result = await this.attempt(hook, queued.event, queued.body, queued.delivery);
|
|
1077
|
+
if (result.ok) {
|
|
1078
|
+
await this.queue.remove(id);
|
|
1079
|
+
sent++;
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
const delay = QUEUE_DELAYS[attempts - this.retryDelays.length - 1];
|
|
1083
|
+
if (delay === void 0) failed++;
|
|
1084
|
+
await this.queue.update(id, {
|
|
1085
|
+
...queued,
|
|
1086
|
+
attempts,
|
|
1087
|
+
nextAttemptAt: later(now, delay ?? 0),
|
|
1088
|
+
state: delay === void 0 ? "failed" : "pending",
|
|
1089
|
+
error: result.error
|
|
1090
|
+
});
|
|
1091
|
+
if (delay === void 0) this.giveUp(hook, queued.event, result.error, attempts);
|
|
1092
|
+
}
|
|
1093
|
+
return { sent, failed };
|
|
1094
|
+
}
|
|
1095
|
+
track(delivery) {
|
|
1096
|
+
const tracked = delivery.catch((error) => this.logger.error(`Webhook queue failed: ${error.message}`)).finally(() => this.pending.delete(tracked));
|
|
1097
|
+
this.pending.add(tracked);
|
|
1098
|
+
}
|
|
1038
1099
|
async deliver(hook, payload) {
|
|
1100
|
+
const delivery = randomUUID2();
|
|
1039
1101
|
const body = JSON.stringify(payload);
|
|
1102
|
+
const { event } = payload;
|
|
1103
|
+
let queued;
|
|
1104
|
+
for (let attempt = 0; ; attempt++) {
|
|
1105
|
+
const result = await this.attempt(hook, event, body, delivery);
|
|
1106
|
+
if (result.ok) {
|
|
1107
|
+
if (queued !== void 0) await this.queue?.remove(queued);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const entry = (nextAttemptAt) => ({
|
|
1111
|
+
url: hook.url,
|
|
1112
|
+
event,
|
|
1113
|
+
body,
|
|
1114
|
+
delivery,
|
|
1115
|
+
attempts: attempt + 1,
|
|
1116
|
+
nextAttemptAt,
|
|
1117
|
+
state: "pending",
|
|
1118
|
+
error: result.error
|
|
1119
|
+
});
|
|
1120
|
+
const delay = this.retryDelays[attempt];
|
|
1121
|
+
if (delay === void 0) {
|
|
1122
|
+
if (!this.queue) return this.giveUp(hook, event, result.error, attempt + 1);
|
|
1123
|
+
const next = entry(later(/* @__PURE__ */ new Date(), QUEUE_DELAYS[0]));
|
|
1124
|
+
if (queued === void 0) await this.queue.add(next);
|
|
1125
|
+
else await this.queue.update(queued, next);
|
|
1126
|
+
this.logger.warn(
|
|
1127
|
+
`Webhook ${hook.url} failed for ${event} (${result.error}); will retry later`
|
|
1128
|
+
);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (this.queue && queued === void 0) {
|
|
1132
|
+
queued = await this.queue.add(entry(later(/* @__PURE__ */ new Date(), CLAIM)));
|
|
1133
|
+
}
|
|
1134
|
+
await new Promise((resolve3) => setTimeout(resolve3, delay));
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
/** One POST. Client errors other than rate limiting count as done: retrying won't help. */
|
|
1138
|
+
async attempt(hook, event, body, delivery) {
|
|
1040
1139
|
const headers = {
|
|
1041
1140
|
"content-type": "application/json",
|
|
1042
1141
|
"user-agent": "easy-cms-webhooks",
|
|
1043
|
-
"x-easy-cms-event":
|
|
1044
|
-
"x-easy-cms-delivery":
|
|
1142
|
+
"x-easy-cms-event": event,
|
|
1143
|
+
"x-easy-cms-delivery": delivery,
|
|
1045
1144
|
...hook.headers
|
|
1046
1145
|
};
|
|
1047
1146
|
if (hook.secret) {
|
|
1048
1147
|
headers["x-easy-cms-signature"] = `sha256=${createHmac2("sha256", hook.secret).update(body).digest("hex")}`;
|
|
1049
1148
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
if (response.ok
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
return;
|
|
1062
|
-
}
|
|
1063
|
-
throw new Error(`status ${response.status}`);
|
|
1064
|
-
} catch (error) {
|
|
1065
|
-
const delay = this.retryDelays[attempt];
|
|
1066
|
-
if (delay === void 0) {
|
|
1067
|
-
this.logger.error(
|
|
1068
|
-
`Webhook ${hook.url} failed for ${payload.event}: ${error.message}`
|
|
1069
|
-
);
|
|
1070
|
-
return;
|
|
1071
|
-
}
|
|
1072
|
-
await new Promise((resolve3) => setTimeout(resolve3, delay));
|
|
1149
|
+
try {
|
|
1150
|
+
const response = await this.fetcher(hook.url, {
|
|
1151
|
+
method: "POST",
|
|
1152
|
+
headers,
|
|
1153
|
+
body,
|
|
1154
|
+
signal: AbortSignal.timeout(TIMEOUT)
|
|
1155
|
+
});
|
|
1156
|
+
if (response.ok || response.status < 500 && response.status !== 429) {
|
|
1157
|
+
if (!response.ok)
|
|
1158
|
+
this.logger.warn(`Webhook ${hook.url} answered ${response.status} to ${event}`);
|
|
1159
|
+
return { ok: true };
|
|
1073
1160
|
}
|
|
1161
|
+
return { ok: false, error: `status ${response.status}` };
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
return { ok: false, error: error.message };
|
|
1074
1164
|
}
|
|
1075
1165
|
}
|
|
1166
|
+
giveUp(hook, event, error, attempts) {
|
|
1167
|
+
this.logger.error(
|
|
1168
|
+
`Webhook ${hook.url} failed for ${event} after ${attempts} attempts: ${error}`
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1076
1171
|
};
|
|
1172
|
+
var later = (from, ms) => new Date(from.getTime() + ms).toISOString();
|
|
1077
1173
|
function matches(hook, payload) {
|
|
1078
1174
|
if (hook.events && !hook.events.includes(payload.event)) return false;
|
|
1079
1175
|
if (payload.collection !== void 0)
|
|
@@ -1091,6 +1187,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
1091
1187
|
"login-attempts",
|
|
1092
1188
|
"document-versions",
|
|
1093
1189
|
"scheduled-jobs",
|
|
1190
|
+
"webhook-deliveries",
|
|
1094
1191
|
"jobs",
|
|
1095
1192
|
"migrations",
|
|
1096
1193
|
"access"
|
|
@@ -1580,7 +1677,8 @@ async function resolveConfig(input) {
|
|
|
1580
1677
|
...config.collections ?? [],
|
|
1581
1678
|
...internalCollections,
|
|
1582
1679
|
...usesVersions(config) ? [versionsCollection] : [],
|
|
1583
|
-
...usesSchedule(config) ? [scheduledJobsCollection] : []
|
|
1680
|
+
...usesSchedule(config) ? [scheduledJobsCollection] : [],
|
|
1681
|
+
...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : []
|
|
1584
1682
|
],
|
|
1585
1683
|
globals: config.globals ?? []
|
|
1586
1684
|
};
|
|
@@ -1712,6 +1810,10 @@ function localizePath(fields, path, locale, locales = []) {
|
|
|
1712
1810
|
list = field.fields;
|
|
1713
1811
|
continue;
|
|
1714
1812
|
}
|
|
1813
|
+
if (field.type === "blocks") {
|
|
1814
|
+
list = field.blocks.flatMap((block) => block.fields);
|
|
1815
|
+
continue;
|
|
1816
|
+
}
|
|
1715
1817
|
return path;
|
|
1716
1818
|
}
|
|
1717
1819
|
return path;
|
|
@@ -2099,7 +2201,7 @@ async function createEasyCMS(config, options = {}) {
|
|
|
2099
2201
|
interactive: options.interactive ?? false
|
|
2100
2202
|
});
|
|
2101
2203
|
const cms = new EasyCMS(resolved2, db, logger, storage);
|
|
2102
|
-
const scheduling = [...resolved2.collections, ...resolved2.globals].some((c) => c.schedule);
|
|
2204
|
+
const scheduling = [...resolved2.collections, ...resolved2.globals].some((c) => c.schedule) || (resolved2.webhooks?.length ?? 0) > 0;
|
|
2103
2205
|
if (scheduling && options.scheduler !== false) cms.startScheduler();
|
|
2104
2206
|
return cms;
|
|
2105
2207
|
}
|
|
@@ -2127,7 +2229,11 @@ var EasyCMS = class {
|
|
|
2127
2229
|
this.logger = logger;
|
|
2128
2230
|
this.storage = storage;
|
|
2129
2231
|
this.versions = new VersionStore(db);
|
|
2130
|
-
this.webhooks = new Webhooks(
|
|
2232
|
+
this.webhooks = new Webhooks(
|
|
2233
|
+
config.webhooks ?? [],
|
|
2234
|
+
logger,
|
|
2235
|
+
config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES) ? this.webhookQueue() : void 0
|
|
2236
|
+
);
|
|
2131
2237
|
this.auth = new Auth(this);
|
|
2132
2238
|
}
|
|
2133
2239
|
async find(collection2, options = {}) {
|
|
@@ -2743,18 +2849,67 @@ var EasyCMS = class {
|
|
|
2743
2849
|
// --- Scheduled publishing --------------------------------------------------
|
|
2744
2850
|
schedulerTimer;
|
|
2745
2851
|
running = Promise.resolve();
|
|
2746
|
-
/** Runs
|
|
2852
|
+
/** Runs `runJobs` every `interval` ms in this process (`createEasyCMS` does this by default). */
|
|
2747
2853
|
startScheduler(interval = 6e4) {
|
|
2748
2854
|
if (this.schedulerTimer) return;
|
|
2749
2855
|
this.schedulerTimer = setInterval(() => {
|
|
2750
2856
|
this.running = this.running.then(
|
|
2751
|
-
() => this.
|
|
2857
|
+
() => this.runJobs().catch(
|
|
2752
2858
|
(error) => this.logger.error(`Scheduled jobs failed: ${error.message}`)
|
|
2753
2859
|
)
|
|
2754
2860
|
);
|
|
2755
2861
|
}, interval);
|
|
2756
2862
|
this.schedulerTimer.unref?.();
|
|
2757
2863
|
}
|
|
2864
|
+
/**
|
|
2865
|
+
* Runs due scheduled publishes and unpublishes, and retries failed webhook deliveries. What the
|
|
2866
|
+
* scheduler and the cron endpoint (`GET <api>/jobs/run`) call.
|
|
2867
|
+
*/
|
|
2868
|
+
async runJobs(now = /* @__PURE__ */ new Date()) {
|
|
2869
|
+
const scheduled = await this.runScheduled(now);
|
|
2870
|
+
return { ...scheduled, webhooks: await this.retryWebhooks(now) };
|
|
2871
|
+
}
|
|
2872
|
+
/**
|
|
2873
|
+
* Tries webhook deliveries that failed earlier and are due for another attempt. Failed
|
|
2874
|
+
* deliveries are kept in the database, so they survive restarts.
|
|
2875
|
+
*/
|
|
2876
|
+
retryWebhooks(now = /* @__PURE__ */ new Date()) {
|
|
2877
|
+
return this.webhooks.retry(now);
|
|
2878
|
+
}
|
|
2879
|
+
/** Failed webhook deliveries, stored in `webhook-deliveries`. */
|
|
2880
|
+
webhookQueue() {
|
|
2881
|
+
const collection2 = WEBHOOK_DELIVERIES;
|
|
2882
|
+
const toRow = (d, createdAt) => {
|
|
2883
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2884
|
+
return { ...d, createdAt: createdAt ?? now, updatedAt: now };
|
|
2885
|
+
};
|
|
2886
|
+
return {
|
|
2887
|
+
add: async (delivery) => (await this.db.create({ collection: collection2, data: toRow(delivery) })).id,
|
|
2888
|
+
update: async (id, delivery) => {
|
|
2889
|
+
const current = await this.db.findById({ collection: collection2, id });
|
|
2890
|
+
await this.db.update({
|
|
2891
|
+
collection: collection2,
|
|
2892
|
+
id,
|
|
2893
|
+
data: toRow(delivery, current?.createdAt)
|
|
2894
|
+
});
|
|
2895
|
+
},
|
|
2896
|
+
remove: async (id) => {
|
|
2897
|
+
await this.db.delete({ collection: collection2, id });
|
|
2898
|
+
},
|
|
2899
|
+
due: async (now, limit) => {
|
|
2900
|
+
const { docs } = await this.db.find({
|
|
2901
|
+
collection: collection2,
|
|
2902
|
+
where: {
|
|
2903
|
+
and: [{ state: { equals: "pending" } }, { nextAttemptAt: { lte: now.toISOString() } }]
|
|
2904
|
+
},
|
|
2905
|
+
sort: ["nextAttemptAt"],
|
|
2906
|
+
limit,
|
|
2907
|
+
page: 1
|
|
2908
|
+
});
|
|
2909
|
+
return docs;
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2758
2913
|
/**
|
|
2759
2914
|
* Publishes or unpublishes documents whose scheduled time has come. Safe to call often, e.g.
|
|
2760
2915
|
* from a cron job (`GET <api>/jobs/run`) where no process keeps running.
|
|
@@ -3693,7 +3848,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
3693
3848
|
if (!byCron && ctx.user?.role !== "admin") {
|
|
3694
3849
|
throw ctx.user ? new HttpError("Forbidden", 403) : new UnauthorizedError();
|
|
3695
3850
|
}
|
|
3696
|
-
return { body: await cms.
|
|
3851
|
+
return { body: await cms.runJobs() };
|
|
3697
3852
|
}
|
|
3698
3853
|
if (first === "admin") {
|
|
3699
3854
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|