@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 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 a collection or
859
- * global has `schedule`. Turn it off where a cron calls `runScheduled` instead.
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 due jobs every `interval` ms in this process (`createEasyCMS` does this by default). */
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
- const delivery = this.deliver(hook, payload).finally(() => this.pending.delete(delivery));
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": payload.event,
1044
- "x-easy-cms-delivery": randomUUID2(),
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
- for (let attempt = 0; ; attempt++) {
1051
- try {
1052
- const response = await this.fetcher(hook.url, {
1053
- method: "POST",
1054
- headers,
1055
- body,
1056
- signal: AbortSignal.timeout(TIMEOUT)
1057
- });
1058
- if (response.ok || response.status < 500 && response.status !== 429) {
1059
- if (!response.ok)
1060
- this.logger.warn(`Webhook ${hook.url} answered ${response.status} to ${payload.event}`);
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(config.webhooks ?? [], logger);
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 due jobs every `interval` ms in this process (`createEasyCMS` does this by default). */
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.runScheduled().catch(
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.runScheduled() };
3859
+ return { body: await cms.runJobs() };
3697
3860
  }
3698
3861
  if (first === "admin") {
3699
3862
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",