@rmdes/indiekit-endpoint-syndicate 1.0.0-beta.38 → 1.0.0-beta.40

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.
@@ -15,6 +15,24 @@ import {
15
15
  */
16
16
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
17
17
 
18
+ /**
19
+ * Guards batch mode against overlapping runs.
20
+ *
21
+ * A post stays selectable until its Micropub update lands at the end of a
22
+ * batch, so a second trigger arriving mid-batch sees `mp-syndicate-to` still
23
+ * set and syndicates it again. Both triggers — the poller and the Eleventy
24
+ * post-build hook — reach the same process, so a module-level flag is enough.
25
+ * Anything skipped is picked up by the next poll.
26
+ *
27
+ * Batch mode only: the single-post force path (source_url) is deliberately
28
+ * unguarded so manual re-syndication always runs.
29
+ *
30
+ * ponytail: single-process guard. If Indiekit is ever run multi-process or
31
+ * multi-container, replace with an atomic per-post claim (findOneAndUpdate
32
+ * setting a claim timestamp, with a stale-claim timeout).
33
+ */
34
+ const batch = { isRunning: false };
35
+
18
36
  /**
19
37
  * Syndicate a single post and update it via Micropub
20
38
  * @param {object} options - Options
@@ -36,17 +54,24 @@ const syndicatePost = async ({
36
54
  // Skip check in force mode (manual re-syndication from UI/backlog script).
37
55
  // Photo posts skip OG check — Eleventy deliberately skips OG generation
38
56
  // for posts with photos (the photo itself serves as og:image in HTML).
39
- const meUrl = typeof publication.me === "string" ? publication.me : publication.me?.href || publication.me?.toString?.() || "";
40
- const hasPhotos = Array.isArray(postData.properties?.photo) && postData.properties.photo.length > 0;
57
+ const meUrl =
58
+ typeof publication.me === "string"
59
+ ? publication.me
60
+ : publication.me?.href || publication.me?.toString?.() || "";
61
+ const hasPhotos =
62
+ Array.isArray(postData.properties?.photo) &&
63
+ postData.properties.photo.length > 0;
41
64
  if (!force && meUrl && postData.properties?.url) {
42
- console.log(`[syndication] Readiness gate: checking ${postData.properties.url} (me=${meUrl}, hasPhotos=${hasPhotos})`);
65
+ console.log(
66
+ `[syndication] Readiness gate: checking ${postData.properties.url} (me=${meUrl}, hasPhotos=${hasPhotos})`,
67
+ );
43
68
  const readiness = hasPhotos
44
69
  ? await isPostReady(postData.properties.url, meUrl, { skipOg: true })
45
70
  : await isPostReady(postData.properties.url, meUrl);
46
71
  if (!readiness.ready) {
47
72
  console.log(
48
73
  `[syndication] Skipping ${postData.properties.url} — not yet built ` +
49
- `(post: ${readiness.postStatus}, og: ${readiness.ogStatus})`
74
+ `(post: ${readiness.postStatus}, og: ${readiness.ogStatus})`,
50
75
  );
51
76
  return { skipped: true, url: postData.properties.url, readiness };
52
77
  }
@@ -81,7 +106,9 @@ const syndicatePost = async ({
81
106
  throw await IndiekitError.fromFetch(micropubResponse);
82
107
  }
83
108
 
84
- /** @type {object} */
109
+ /**
110
+ @type {object}
111
+ */
85
112
  const body = await micropubResponse.json();
86
113
 
87
114
  return {
@@ -153,87 +180,97 @@ export const syndicateController = {
153
180
  }
154
181
 
155
182
  // --- Batch mode (no source_url — process ALL pending posts) ---
156
- const allPostData = await getAllPostData(postsCollection);
157
-
158
- if (!allPostData || allPostData.length === 0) {
183
+ if (batch.isRunning) {
184
+ console.log("[syndication] Batch already in progress, skipping");
159
185
  return response.json({
160
186
  success: "OK",
161
- success_description: "No posts awaiting syndication",
187
+ success_description: "Batch already in progress",
162
188
  });
163
189
  }
164
190
 
165
- console.log(
166
- `[syndication] Batch processing ${allPostData.length} post(s)`,
167
- );
191
+ batch.isRunning = true;
168
192
 
169
- const results = [];
170
- let successCount = 0;
171
- let failCount = 0;
172
- let skippedCount = 0;
173
-
174
- for (const postData of allPostData) {
175
- try {
176
- const result = await syndicatePost({
177
- application,
178
- publication,
179
- postData,
180
- bearerToken,
193
+ try {
194
+ const allPostData = await getAllPostData(postsCollection);
195
+
196
+ if (!allPostData || allPostData.length === 0) {
197
+ return response.json({
198
+ success: "OK",
199
+ success_description: "No posts awaiting syndication",
181
200
  });
201
+ }
202
+
203
+ console.log(
204
+ `[syndication] Batch processing ${allPostData.length} post(s)`,
205
+ );
206
+
207
+ const results = [];
208
+
209
+ for (const [index, postData] of allPostData.entries()) {
210
+ try {
211
+ const result = await syndicatePost({
212
+ application,
213
+ publication,
214
+ postData,
215
+ bearerToken,
216
+ });
217
+
218
+ // Post was skipped (not yet built)
219
+ if (result.skipped) {
220
+ results.push({
221
+ url: result.url,
222
+ success: true,
223
+ skipped: true,
224
+ reason: `Not yet built (post: ${result.readiness.postStatus}, og: ${result.readiness.ogStatus})`,
225
+ });
226
+ continue;
227
+ }
182
228
 
183
- // Post was skipped (not yet built)
184
- if (result.skipped) {
185
229
  results.push({
186
230
  url: result.url,
187
231
  success: true,
188
- skipped: true,
189
- reason: `Not yet built (post: ${result.readiness.postStatus}, og: ${result.readiness.ogStatus})`,
232
+ syndicatedUrls: result.syndicatedUrls,
233
+ ...(result.failedTargets && {
234
+ failedTargets: result.failedTargets,
235
+ }),
190
236
  });
191
- skippedCount++;
192
- continue;
193
- }
194
-
195
- results.push({
196
- url: result.url,
197
- success: true,
198
- syndicatedUrls: result.syndicatedUrls,
199
- ...(result.failedTargets && {
200
- failedTargets: result.failedTargets,
201
- }),
202
- });
203
237
 
204
- successCount++;
205
- console.log(
206
- `[syndication] Syndicated: ${result.url} (${result.syndicatedUrls.length} target(s))`,
207
- );
208
- } catch (error) {
209
- results.push({
210
- url: postData.properties?.url,
211
- success: false,
212
- error: error.message,
213
- });
238
+ console.log(
239
+ `[syndication] Syndicated: ${result.url} (${result.syndicatedUrls.length} target(s))`,
240
+ );
241
+ } catch (error) {
242
+ results.push({
243
+ url: postData.properties?.url,
244
+ success: false,
245
+ error: error.message,
246
+ });
214
247
 
215
- failCount++;
216
- console.error(
217
- `[syndication] Failed: ${postData.properties?.url} - ${error.message}`,
218
- );
219
- }
248
+ console.error(
249
+ `[syndication] Failed: ${postData.properties?.url} - ${error.message}`,
250
+ );
251
+ }
220
252
 
221
- // Rate limit delay between posts (2 seconds)
222
- if (allPostData.indexOf(postData) < allPostData.length - 1) {
223
- await delay(2000);
253
+ // Rate limit delay between posts (2 seconds)
254
+ if (index < allPostData.length - 1) {
255
+ await delay(2000);
256
+ }
224
257
  }
225
- }
226
258
 
227
- const description =
228
- `Processed ${allPostData.length} post(s): ${successCount} succeeded, ${failCount} failed, ${skippedCount} not yet built`;
259
+ const failed = results.filter((result) => !result.success).length;
260
+ const skipped = results.filter((result) => result.skipped).length;
261
+ const succeeded = results.length - failed - skipped;
262
+ const description = `Processed ${allPostData.length} post(s): ${succeeded} succeeded, ${failed} failed, ${skipped} not yet built`;
229
263
 
230
- console.log(`[syndication] ${description}`);
264
+ console.log(`[syndication] ${description}`);
231
265
 
232
- return response.json({
233
- success: "OK",
234
- success_description: description,
235
- results,
236
- });
266
+ return response.json({
267
+ success: "OK",
268
+ success_description: description,
269
+ results,
270
+ });
271
+ } finally {
272
+ batch.isRunning = false;
273
+ }
237
274
  } catch (error) {
238
275
  next(error);
239
276
  }
package/lib/utils.js CHANGED
@@ -5,36 +5,31 @@
5
5
  * @returns {Promise<object>} Post data for given URL else most recent pending post
6
6
  */
7
7
  export const getPostData = async (postsCollection, url) => {
8
- let postData = {};
9
-
10
8
  if (url) {
11
- // Get item in database with matching URL
12
- postData = await postsCollection.findOne({
9
+ return postsCollection.findOne({
13
10
  "properties.url": url,
14
11
  });
15
- } else {
16
- // Get most recent published post awaiting syndication
17
- const items = await postsCollection
18
- .find({
19
- "properties.mp-syndicate-to": {
20
- $exists: true,
21
- },
22
- // BUG FIX: Removed "properties.syndication": { $exists: false }
23
- // That filter skipped partially syndicated posts (e.g., posted to
24
- // Mastodon but not yet to Bluesky). syndicateToTargets() already
25
- // calls hasSyndicationUrl() to skip completed targets.
26
- "properties.post-status": {
27
- $ne: "draft",
28
- },
29
- })
30
- // eslint-disable-next-line unicorn/no-array-sort
31
- .sort({ "properties.published": -1 })
32
- .limit(1)
33
- .toArray();
34
- postData = items[0];
35
12
  }
36
13
 
37
- return postData;
14
+ // A post is awaiting syndication while `mp-syndicate-to` remains: it is
15
+ // deleted once every target has returned a URL, and replaced with the
16
+ // targets that failed otherwise. Posts already syndicated to some of their
17
+ // targets are therefore still awaiting the rest, and `syndicateToTargets`
18
+ // skips the ones already done.
19
+ const items = await postsCollection
20
+ .find({
21
+ "properties.mp-syndicate-to": {
22
+ $exists: true,
23
+ },
24
+ "properties.post-status": {
25
+ $ne: "draft",
26
+ },
27
+ })
28
+ .sort({ "properties.published": -1 })
29
+ .limit(1)
30
+ .toArray();
31
+
32
+ return items[0];
38
33
  };
39
34
 
40
35
  /**
@@ -53,7 +48,7 @@ export const getAllPostData = async (postsCollection) => {
53
48
  $ne: "draft",
54
49
  },
55
50
  })
56
- // eslint-disable-next-line unicorn/no-array-sort
51
+
57
52
  .sort({ "properties.published": -1 })
58
53
  .toArray();
59
54
 
@@ -136,7 +131,9 @@ export const syndicateToTargets = async (
136
131
  syndicateTo = syndicationTargets
137
132
  .filter((t) => {
138
133
  try {
139
- return t?.info?.uid && existingOrigins.has(new URL(t.info.uid).origin);
134
+ return (
135
+ t?.info?.uid && existingOrigins.has(new URL(t.info.uid).origin)
136
+ );
140
137
  } catch {
141
138
  return false;
142
139
  }
@@ -228,7 +225,6 @@ export const syndicateToTargets = async (
228
225
  *
229
226
  * Date-based: /type/YYYY/MM/DD/slug → YYYY-MM-DD-slug
230
227
  * Non-date: /about/ → about
231
- *
232
228
  * @param {string} postUrl - Full post URL
233
229
  * @returns {string|null} OG slug or null
234
230
  */
@@ -240,22 +236,46 @@ export function urlToOgSlug(postUrl) {
240
236
  // Date-based URL: /type/yyyy/MM/dd/slug → 5 segments
241
237
  if (segments.length === 5) {
242
238
  const [, year, month, day, slug] = segments;
243
- if (/^\d{4}$/.test(year) && /^\d{2}$/.test(month) && /^\d{2}$/.test(day)) {
239
+ if (
240
+ /^\d{4}$/.test(year) &&
241
+ /^\d{2}$/.test(month) &&
242
+ /^\d{2}$/.test(day)
243
+ ) {
244
244
  return `${year}-${month}-${day}-${slug}`;
245
245
  }
246
246
  }
247
247
 
248
248
  // Fallback: last segment
249
- return segments[segments.length - 1] || null;
249
+ return segments.at(-1);
250
250
  } catch {
251
- return null;
251
+ return;
252
252
  }
253
253
  }
254
254
 
255
+ /**
256
+ * HEAD a URL and report its status, 0 on network error or timeout
257
+ * @param {string} url - URL to check
258
+ * @returns {Promise<number>} HTTP status
259
+ */
260
+ const headCheck = async (url) => {
261
+ try {
262
+ const controller = new AbortController();
263
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
264
+ const response = await fetch(url, {
265
+ method: "HEAD",
266
+ redirect: "follow",
267
+ signal: controller.signal,
268
+ });
269
+ clearTimeout(timeoutId);
270
+ return response.status;
271
+ } catch {
272
+ return 0;
273
+ }
274
+ };
275
+
255
276
  /**
256
277
  * Check if a post and its OG image are live on the public site.
257
278
  * Uses HTTP HEAD requests with a 5-second timeout.
258
- *
259
279
  * @param {string} postUrl - Full public URL of the post
260
280
  * @param {string} me - Publication URL (e.g., "https://rmendes.net")
261
281
  * @param {object} [options] - Options
@@ -267,23 +287,8 @@ export async function isPostReady(postUrl, me, options = {}) {
267
287
  const { skipOg = false } = options;
268
288
  const ogSlug = urlToOgSlug(postUrl);
269
289
  const meNorm = me?.replace(/\/$/, "");
270
- const ogUrl = !skipOg && ogSlug && meNorm ? `${meNorm}/og/${ogSlug}.png` : null;
271
-
272
- const headCheck = async (url) => {
273
- try {
274
- const controller = new AbortController();
275
- const timeoutId = setTimeout(() => controller.abort(), 5000);
276
- const response = await fetch(url, {
277
- method: "HEAD",
278
- redirect: "follow",
279
- signal: controller.signal,
280
- });
281
- clearTimeout(timeoutId);
282
- return response.status;
283
- } catch {
284
- return 0;
285
- }
286
- };
290
+ const ogUrl =
291
+ !skipOg && ogSlug && meNorm ? `${meNorm}/og/${ogSlug}.png` : undefined;
287
292
 
288
293
  const [postStatus, ogStatus] = await Promise.all([
289
294
  headCheck(postUrl),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-syndicate",
3
- "version": "1.0.0-beta.38",
3
+ "version": "1.0.0-beta.40",
4
4
  "description": "Syndication endpoint for Indiekit. Fork of @indiekit/endpoint-syndicate with batch syndication support and bug fixes.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "license": "MIT",
17
17
  "engines": {
18
- "node": ">=20"
18
+ "node": ">=24.17"
19
19
  },
20
20
  "type": "module",
21
21
  "main": "index.js",
@@ -28,14 +28,17 @@
28
28
  "url": "https://github.com/rmdes/indiekit-endpoint-syndicate.git"
29
29
  },
30
30
  "dependencies": {
31
- "@indiekit/error": "^1.0.0-beta.27",
32
- "express": "^5.0.0",
33
- "jsonwebtoken": "^9.0.0"
31
+ "@indiekit/error": "^1.0.0-beta.28",
32
+ "express": "^5.2.1",
33
+ "jsonwebtoken": "^9.0.3"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@indiekit/indiekit": ">=1.0.0-beta.25"
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
40
+ },
41
+ "scripts": {
42
+ "test": "node --test test/*.js"
40
43
  }
41
44
  }