@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 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 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
- const delivery = this.deliver(hook, payload).finally(() => this.pending.delete(delivery));
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": payload.event,
1044
- "x-easy-cms-delivery": randomUUID2(),
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
- 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));
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(config.webhooks ?? [], logger);
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 due jobs every `interval` ms in this process (`createEasyCMS` does this by default). */
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.runScheduled().catch(
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.runScheduled() };
3851
+ return { body: await cms.runJobs() };
3697
3852
  }
3698
3853
  if (first === "admin") {
3699
3854
  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.7.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",