@easy-cms/core 0.6.0 → 0.8.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 +198 -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 are due for another attempt. Deliveries are kept in the
|
|
1012
|
+
* database until they succeed, so they survive restarts.
|
|
1013
|
+
*/
|
|
1014
|
+
retryWebhooks(now?: Date): Promise<{
|
|
1015
|
+
sent: number;
|
|
1016
|
+
failed: number;
|
|
1017
|
+
}>;
|
|
1018
|
+
/** Webhook deliveries until they succeed, 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,71 +1027,157 @@ 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();
|
|
1047
|
+
/** Deliveries are saved one after another, so they are first sent in the order of events. */
|
|
1048
|
+
saving = Promise.resolve();
|
|
1025
1049
|
emit(event, target, doc) {
|
|
1026
1050
|
if (this.hooks.length === 0) return;
|
|
1027
1051
|
const payload = { event, ...target, doc, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1028
1052
|
for (const hook of this.hooks) {
|
|
1029
1053
|
if (!matches(hook, payload)) continue;
|
|
1030
|
-
|
|
1031
|
-
this.pending.add(delivery);
|
|
1054
|
+
this.track(this.deliver(hook, payload));
|
|
1032
1055
|
}
|
|
1033
1056
|
}
|
|
1034
1057
|
/** Waits for deliveries in progress, e.g. before a serverless function returns. */
|
|
1035
1058
|
async flush() {
|
|
1036
1059
|
while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
|
|
1037
1060
|
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Tries queued deliveries whose next attempt is due. Runs with scheduled jobs: every minute
|
|
1063
|
+
* in a server, or from the cron endpoint.
|
|
1064
|
+
*/
|
|
1065
|
+
async retry(now = /* @__PURE__ */ new Date()) {
|
|
1066
|
+
if (!this.queue) return { sent: 0, failed: 0 };
|
|
1067
|
+
let sent = 0;
|
|
1068
|
+
let failed = 0;
|
|
1069
|
+
for (const entry of await this.queue.due(now, 50)) {
|
|
1070
|
+
const { id, ...queued } = entry;
|
|
1071
|
+
const hook = this.hooks.find((h) => h.url === queued.url);
|
|
1072
|
+
if (!hook) {
|
|
1073
|
+
await this.queue.remove(id);
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const attempts = queued.attempts + 1;
|
|
1077
|
+
await this.queue.update(id, { ...queued, attempts, nextAttemptAt: later(now, CLAIM) });
|
|
1078
|
+
const result = await this.attempt(hook, queued.event, queued.body, queued.delivery);
|
|
1079
|
+
if (result.ok) {
|
|
1080
|
+
await this.queue.remove(id);
|
|
1081
|
+
sent++;
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
const delay = QUEUE_DELAYS[Math.max(0, attempts - this.retryDelays.length - 1)];
|
|
1085
|
+
if (delay === void 0) failed++;
|
|
1086
|
+
await this.queue.update(id, {
|
|
1087
|
+
...queued,
|
|
1088
|
+
attempts,
|
|
1089
|
+
nextAttemptAt: later(now, delay ?? 0),
|
|
1090
|
+
state: delay === void 0 ? "failed" : "pending",
|
|
1091
|
+
error: result.error
|
|
1092
|
+
});
|
|
1093
|
+
if (delay === void 0) this.giveUp(hook, queued.event, result.error, attempts);
|
|
1094
|
+
}
|
|
1095
|
+
return { sent, failed };
|
|
1096
|
+
}
|
|
1097
|
+
track(delivery) {
|
|
1098
|
+
const tracked = delivery.catch((error) => this.logger.error(`Webhook queue failed: ${error.message}`)).finally(() => this.pending.delete(tracked));
|
|
1099
|
+
this.pending.add(tracked);
|
|
1100
|
+
}
|
|
1038
1101
|
async deliver(hook, payload) {
|
|
1102
|
+
const delivery = randomUUID2();
|
|
1039
1103
|
const body = JSON.stringify(payload);
|
|
1104
|
+
const { event } = payload;
|
|
1105
|
+
const entry = (attempts, nextAttemptAt, error) => ({
|
|
1106
|
+
url: hook.url,
|
|
1107
|
+
event,
|
|
1108
|
+
body,
|
|
1109
|
+
delivery,
|
|
1110
|
+
attempts,
|
|
1111
|
+
nextAttemptAt,
|
|
1112
|
+
state: "pending",
|
|
1113
|
+
error
|
|
1114
|
+
});
|
|
1115
|
+
const { queue } = this;
|
|
1116
|
+
const saved = queue ? this.saving.then(() => queue.add(entry(0, later(/* @__PURE__ */ new Date(), CLAIM), null))) : Promise.resolve(void 0);
|
|
1117
|
+
this.saving = saved.catch(() => {
|
|
1118
|
+
});
|
|
1119
|
+
let queued;
|
|
1120
|
+
try {
|
|
1121
|
+
queued = await saved;
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
this.logger.error(`Could not save webhook delivery: ${error.message}`);
|
|
1124
|
+
}
|
|
1125
|
+
for (let attempt = 0; ; attempt++) {
|
|
1126
|
+
const result = await this.attempt(hook, event, body, delivery);
|
|
1127
|
+
if (result.ok) {
|
|
1128
|
+
if (queued !== void 0) await this.queue?.remove(queued);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const delay = this.retryDelays[attempt];
|
|
1132
|
+
if (delay === void 0) {
|
|
1133
|
+
if (queued === void 0 || !this.queue)
|
|
1134
|
+
return this.giveUp(hook, event, result.error, attempt + 1);
|
|
1135
|
+
const next = later(/* @__PURE__ */ new Date(), QUEUE_DELAYS[0]);
|
|
1136
|
+
await this.queue.update(queued, entry(attempt + 1, next, result.error));
|
|
1137
|
+
this.logger.warn(
|
|
1138
|
+
`Webhook ${hook.url} failed for ${event} (${result.error}); will retry later`
|
|
1139
|
+
);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
await new Promise((resolve3) => setTimeout(resolve3, delay));
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
/** One POST. Client errors other than rate limiting count as done: retrying won't help. */
|
|
1146
|
+
async attempt(hook, event, body, delivery) {
|
|
1040
1147
|
const headers = {
|
|
1041
1148
|
"content-type": "application/json",
|
|
1042
1149
|
"user-agent": "easy-cms-webhooks",
|
|
1043
|
-
"x-easy-cms-event":
|
|
1044
|
-
"x-easy-cms-delivery":
|
|
1150
|
+
"x-easy-cms-event": event,
|
|
1151
|
+
"x-easy-cms-delivery": delivery,
|
|
1045
1152
|
...hook.headers
|
|
1046
1153
|
};
|
|
1047
1154
|
if (hook.secret) {
|
|
1048
1155
|
headers["x-easy-cms-signature"] = `sha256=${createHmac2("sha256", hook.secret).update(body).digest("hex")}`;
|
|
1049
1156
|
}
|
|
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));
|
|
1157
|
+
try {
|
|
1158
|
+
const response = await this.fetcher(hook.url, {
|
|
1159
|
+
method: "POST",
|
|
1160
|
+
headers,
|
|
1161
|
+
body,
|
|
1162
|
+
signal: AbortSignal.timeout(TIMEOUT)
|
|
1163
|
+
});
|
|
1164
|
+
if (response.ok || response.status < 500 && response.status !== 429) {
|
|
1165
|
+
if (!response.ok)
|
|
1166
|
+
this.logger.warn(`Webhook ${hook.url} answered ${response.status} to ${event}`);
|
|
1167
|
+
return { ok: true };
|
|
1073
1168
|
}
|
|
1169
|
+
return { ok: false, error: `status ${response.status}` };
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
return { ok: false, error: error.message };
|
|
1074
1172
|
}
|
|
1075
1173
|
}
|
|
1174
|
+
giveUp(hook, event, error, attempts) {
|
|
1175
|
+
this.logger.error(
|
|
1176
|
+
`Webhook ${hook.url} failed for ${event} after ${attempts} attempts: ${error}`
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1076
1179
|
};
|
|
1180
|
+
var later = (from, ms) => new Date(from.getTime() + ms).toISOString();
|
|
1077
1181
|
function matches(hook, payload) {
|
|
1078
1182
|
if (hook.events && !hook.events.includes(payload.event)) return false;
|
|
1079
1183
|
if (payload.collection !== void 0)
|
|
@@ -1091,6 +1195,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
1091
1195
|
"login-attempts",
|
|
1092
1196
|
"document-versions",
|
|
1093
1197
|
"scheduled-jobs",
|
|
1198
|
+
"webhook-deliveries",
|
|
1094
1199
|
"jobs",
|
|
1095
1200
|
"migrations",
|
|
1096
1201
|
"access"
|
|
@@ -1580,7 +1685,8 @@ async function resolveConfig(input) {
|
|
|
1580
1685
|
...config.collections ?? [],
|
|
1581
1686
|
...internalCollections,
|
|
1582
1687
|
...usesVersions(config) ? [versionsCollection] : [],
|
|
1583
|
-
...usesSchedule(config) ? [scheduledJobsCollection] : []
|
|
1688
|
+
...usesSchedule(config) ? [scheduledJobsCollection] : [],
|
|
1689
|
+
...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : []
|
|
1584
1690
|
],
|
|
1585
1691
|
globals: config.globals ?? []
|
|
1586
1692
|
};
|
|
@@ -1712,6 +1818,10 @@ function localizePath(fields, path, locale, locales = []) {
|
|
|
1712
1818
|
list = field.fields;
|
|
1713
1819
|
continue;
|
|
1714
1820
|
}
|
|
1821
|
+
if (field.type === "blocks") {
|
|
1822
|
+
list = field.blocks.flatMap((block) => block.fields);
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1715
1825
|
return path;
|
|
1716
1826
|
}
|
|
1717
1827
|
return path;
|
|
@@ -2099,7 +2209,7 @@ async function createEasyCMS(config, options = {}) {
|
|
|
2099
2209
|
interactive: options.interactive ?? false
|
|
2100
2210
|
});
|
|
2101
2211
|
const cms = new EasyCMS(resolved2, db, logger, storage);
|
|
2102
|
-
const scheduling = [...resolved2.collections, ...resolved2.globals].some((c) => c.schedule);
|
|
2212
|
+
const scheduling = [...resolved2.collections, ...resolved2.globals].some((c) => c.schedule) || (resolved2.webhooks?.length ?? 0) > 0;
|
|
2103
2213
|
if (scheduling && options.scheduler !== false) cms.startScheduler();
|
|
2104
2214
|
return cms;
|
|
2105
2215
|
}
|
|
@@ -2127,7 +2237,11 @@ var EasyCMS = class {
|
|
|
2127
2237
|
this.logger = logger;
|
|
2128
2238
|
this.storage = storage;
|
|
2129
2239
|
this.versions = new VersionStore(db);
|
|
2130
|
-
this.webhooks = new Webhooks(
|
|
2240
|
+
this.webhooks = new Webhooks(
|
|
2241
|
+
config.webhooks ?? [],
|
|
2242
|
+
logger,
|
|
2243
|
+
config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES) ? this.webhookQueue() : void 0
|
|
2244
|
+
);
|
|
2131
2245
|
this.auth = new Auth(this);
|
|
2132
2246
|
}
|
|
2133
2247
|
async find(collection2, options = {}) {
|
|
@@ -2743,18 +2857,67 @@ var EasyCMS = class {
|
|
|
2743
2857
|
// --- Scheduled publishing --------------------------------------------------
|
|
2744
2858
|
schedulerTimer;
|
|
2745
2859
|
running = Promise.resolve();
|
|
2746
|
-
/** Runs
|
|
2860
|
+
/** Runs `runJobs` every `interval` ms in this process (`createEasyCMS` does this by default). */
|
|
2747
2861
|
startScheduler(interval = 6e4) {
|
|
2748
2862
|
if (this.schedulerTimer) return;
|
|
2749
2863
|
this.schedulerTimer = setInterval(() => {
|
|
2750
2864
|
this.running = this.running.then(
|
|
2751
|
-
() => this.
|
|
2865
|
+
() => this.runJobs().catch(
|
|
2752
2866
|
(error) => this.logger.error(`Scheduled jobs failed: ${error.message}`)
|
|
2753
2867
|
)
|
|
2754
2868
|
);
|
|
2755
2869
|
}, interval);
|
|
2756
2870
|
this.schedulerTimer.unref?.();
|
|
2757
2871
|
}
|
|
2872
|
+
/**
|
|
2873
|
+
* Runs due scheduled publishes and unpublishes, and retries failed webhook deliveries. What the
|
|
2874
|
+
* scheduler and the cron endpoint (`GET <api>/jobs/run`) call.
|
|
2875
|
+
*/
|
|
2876
|
+
async runJobs(now = /* @__PURE__ */ new Date()) {
|
|
2877
|
+
const scheduled = await this.runScheduled(now);
|
|
2878
|
+
return { ...scheduled, webhooks: await this.retryWebhooks(now) };
|
|
2879
|
+
}
|
|
2880
|
+
/**
|
|
2881
|
+
* Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
|
|
2882
|
+
* database until they succeed, so they survive restarts.
|
|
2883
|
+
*/
|
|
2884
|
+
retryWebhooks(now = /* @__PURE__ */ new Date()) {
|
|
2885
|
+
return this.webhooks.retry(now);
|
|
2886
|
+
}
|
|
2887
|
+
/** Webhook deliveries until they succeed, stored in `webhook-deliveries`. */
|
|
2888
|
+
webhookQueue() {
|
|
2889
|
+
const collection2 = WEBHOOK_DELIVERIES;
|
|
2890
|
+
const toRow = (d, createdAt) => {
|
|
2891
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2892
|
+
return { ...d, createdAt: createdAt ?? now, updatedAt: now };
|
|
2893
|
+
};
|
|
2894
|
+
return {
|
|
2895
|
+
add: async (delivery) => (await this.db.create({ collection: collection2, data: toRow(delivery) })).id,
|
|
2896
|
+
update: async (id, delivery) => {
|
|
2897
|
+
const current = await this.db.findById({ collection: collection2, id });
|
|
2898
|
+
await this.db.update({
|
|
2899
|
+
collection: collection2,
|
|
2900
|
+
id,
|
|
2901
|
+
data: toRow(delivery, current?.createdAt)
|
|
2902
|
+
});
|
|
2903
|
+
},
|
|
2904
|
+
remove: async (id) => {
|
|
2905
|
+
await this.db.delete({ collection: collection2, id });
|
|
2906
|
+
},
|
|
2907
|
+
due: async (now, limit) => {
|
|
2908
|
+
const { docs } = await this.db.find({
|
|
2909
|
+
collection: collection2,
|
|
2910
|
+
where: {
|
|
2911
|
+
and: [{ state: { equals: "pending" } }, { nextAttemptAt: { lte: now.toISOString() } }]
|
|
2912
|
+
},
|
|
2913
|
+
sort: ["nextAttemptAt"],
|
|
2914
|
+
limit,
|
|
2915
|
+
page: 1
|
|
2916
|
+
});
|
|
2917
|
+
return docs;
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2758
2921
|
/**
|
|
2759
2922
|
* Publishes or unpublishes documents whose scheduled time has come. Safe to call often, e.g.
|
|
2760
2923
|
* from a cron job (`GET <api>/jobs/run`) where no process keeps running.
|
|
@@ -3693,7 +3856,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
3693
3856
|
if (!byCron && ctx.user?.role !== "admin") {
|
|
3694
3857
|
throw ctx.user ? new HttpError("Forbidden", 403) : new UnauthorizedError();
|
|
3695
3858
|
}
|
|
3696
|
-
return { body: await cms.
|
|
3859
|
+
return { body: await cms.runJobs() };
|
|
3697
3860
|
}
|
|
3698
3861
|
if (first === "admin") {
|
|
3699
3862
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|