@gravito/constellation 3.0.1 → 3.1.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/README.md +96 -251
- package/README.zh-TW.md +130 -13
- package/dist/{DiskSitemapStorage-7ZZMGC4K.js → DiskSitemapStorage-VLN5I24C.js} +1 -1
- package/dist/chunk-3IZTXYU7.js +166 -0
- package/dist/index.cjs +1893 -196
- package/dist/index.d.cts +1597 -171
- package/dist/index.d.ts +1597 -171
- package/dist/index.js +1774 -199
- package/package.json +7 -5
- package/dist/chunk-7WHLC3OJ.js +0 -56
package/dist/index.js
CHANGED
|
@@ -1,35 +1,84 @@
|
|
|
1
1
|
import {
|
|
2
|
-
DiskSitemapStorage
|
|
3
|
-
|
|
2
|
+
DiskSitemapStorage,
|
|
3
|
+
compressToBuffer,
|
|
4
|
+
createCompressionStream,
|
|
5
|
+
fromGzipFilename,
|
|
6
|
+
toGzipFilename
|
|
7
|
+
} from "./chunk-3IZTXYU7.js";
|
|
4
8
|
|
|
5
9
|
// src/core/ChangeTracker.ts
|
|
6
10
|
var MemoryChangeTracker = class {
|
|
7
11
|
changes = [];
|
|
12
|
+
urlIndex = /* @__PURE__ */ new Map();
|
|
8
13
|
maxChanges;
|
|
9
14
|
constructor(options = {}) {
|
|
10
15
|
this.maxChanges = options.maxChanges || 1e5;
|
|
11
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Record a new site structure change in memory.
|
|
19
|
+
*
|
|
20
|
+
* @param change - The change event to record.
|
|
21
|
+
*/
|
|
12
22
|
async track(change) {
|
|
13
23
|
this.changes.push(change);
|
|
24
|
+
const urlChanges = this.urlIndex.get(change.url) || [];
|
|
25
|
+
urlChanges.push(change);
|
|
26
|
+
this.urlIndex.set(change.url, urlChanges);
|
|
14
27
|
if (this.changes.length > this.maxChanges) {
|
|
15
|
-
|
|
28
|
+
const removed = this.changes.shift();
|
|
29
|
+
if (removed) {
|
|
30
|
+
const changes = this.urlIndex.get(removed.url);
|
|
31
|
+
if (changes) {
|
|
32
|
+
const index = changes.indexOf(removed);
|
|
33
|
+
if (index > -1) {
|
|
34
|
+
changes.splice(index, 1);
|
|
35
|
+
}
|
|
36
|
+
if (changes.length === 0) {
|
|
37
|
+
this.urlIndex.delete(removed.url);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
16
41
|
}
|
|
17
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Retrieve all changes recorded in memory since a specific time.
|
|
45
|
+
*
|
|
46
|
+
* @param since - Optional start date for the query.
|
|
47
|
+
* @returns An array of change events.
|
|
48
|
+
*/
|
|
18
49
|
async getChanges(since) {
|
|
19
50
|
if (!since) {
|
|
20
51
|
return [...this.changes];
|
|
21
52
|
}
|
|
22
53
|
return this.changes.filter((change) => change.timestamp >= since);
|
|
23
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Retrieve the full change history for a specific URL from memory.
|
|
57
|
+
*
|
|
58
|
+
* @param url - The URL to query history for.
|
|
59
|
+
* @returns An array of change events.
|
|
60
|
+
*/
|
|
24
61
|
async getChangesByUrl(url) {
|
|
25
|
-
return this.
|
|
62
|
+
return this.urlIndex.get(url) || [];
|
|
26
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Purge old change records from memory storage.
|
|
66
|
+
*
|
|
67
|
+
* @param since - If provided, only records older than this date will be cleared.
|
|
68
|
+
*/
|
|
27
69
|
async clear(since) {
|
|
28
70
|
if (!since) {
|
|
29
71
|
this.changes = [];
|
|
72
|
+
this.urlIndex.clear();
|
|
30
73
|
return;
|
|
31
74
|
}
|
|
32
75
|
this.changes = this.changes.filter((change) => change.timestamp < since);
|
|
76
|
+
this.urlIndex.clear();
|
|
77
|
+
for (const change of this.changes) {
|
|
78
|
+
const urlChanges = this.urlIndex.get(change.url) || [];
|
|
79
|
+
urlChanges.push(change);
|
|
80
|
+
this.urlIndex.set(change.url, urlChanges);
|
|
81
|
+
}
|
|
33
82
|
}
|
|
34
83
|
};
|
|
35
84
|
var RedisChangeTracker = class {
|
|
@@ -47,6 +96,11 @@ var RedisChangeTracker = class {
|
|
|
47
96
|
getListKey() {
|
|
48
97
|
return `${this.keyPrefix}list`;
|
|
49
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Record a new site structure change in Redis.
|
|
101
|
+
*
|
|
102
|
+
* @param change - The change event to record.
|
|
103
|
+
*/
|
|
50
104
|
async track(change) {
|
|
51
105
|
const key = this.getKey(change.url);
|
|
52
106
|
const listKey = this.getListKey();
|
|
@@ -56,6 +110,12 @@ var RedisChangeTracker = class {
|
|
|
56
110
|
await this.client.zadd(listKey, score, change.url);
|
|
57
111
|
await this.client.expire(listKey, this.ttl);
|
|
58
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Retrieve all changes recorded in Redis since a specific time.
|
|
115
|
+
*
|
|
116
|
+
* @param since - Optional start date for the query.
|
|
117
|
+
* @returns An array of change events.
|
|
118
|
+
*/
|
|
59
119
|
async getChanges(since) {
|
|
60
120
|
try {
|
|
61
121
|
const listKey = this.getListKey();
|
|
@@ -76,6 +136,12 @@ var RedisChangeTracker = class {
|
|
|
76
136
|
return [];
|
|
77
137
|
}
|
|
78
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Retrieve the full change history for a specific URL from Redis.
|
|
141
|
+
*
|
|
142
|
+
* @param url - The URL to query history for.
|
|
143
|
+
* @returns An array of change events.
|
|
144
|
+
*/
|
|
79
145
|
async getChangesByUrl(url) {
|
|
80
146
|
try {
|
|
81
147
|
const key = this.getKey(url);
|
|
@@ -90,6 +156,11 @@ var RedisChangeTracker = class {
|
|
|
90
156
|
return [];
|
|
91
157
|
}
|
|
92
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Purge old change records from Redis storage.
|
|
161
|
+
*
|
|
162
|
+
* @param since - If provided, only records older than this date will be cleared.
|
|
163
|
+
*/
|
|
93
164
|
async clear(since) {
|
|
94
165
|
try {
|
|
95
166
|
const listKey = this.getListKey();
|
|
@@ -119,7 +190,11 @@ var DiffCalculator = class {
|
|
|
119
190
|
this.batchSize = options.batchSize || 1e4;
|
|
120
191
|
}
|
|
121
192
|
/**
|
|
122
|
-
*
|
|
193
|
+
* Calculates the difference between two sets of sitemap entries.
|
|
194
|
+
*
|
|
195
|
+
* @param oldEntries - The previous set of sitemap entries.
|
|
196
|
+
* @param newEntries - The current set of sitemap entries.
|
|
197
|
+
* @returns A DiffResult containing added, updated, and removed entries.
|
|
123
198
|
*/
|
|
124
199
|
calculate(oldEntries, newEntries) {
|
|
125
200
|
const oldMap = /* @__PURE__ */ new Map();
|
|
@@ -149,7 +224,11 @@ var DiffCalculator = class {
|
|
|
149
224
|
return { added, updated, removed };
|
|
150
225
|
}
|
|
151
226
|
/**
|
|
152
|
-
*
|
|
227
|
+
* Batch calculates differences for large datasets using async iterables.
|
|
228
|
+
*
|
|
229
|
+
* @param oldEntries - An async iterable of the previous sitemap entries.
|
|
230
|
+
* @param newEntries - An async iterable of the current sitemap entries.
|
|
231
|
+
* @returns A promise resolving to the DiffResult.
|
|
153
232
|
*/
|
|
154
233
|
async calculateBatch(oldEntries, newEntries) {
|
|
155
234
|
const oldMap = /* @__PURE__ */ new Map();
|
|
@@ -163,7 +242,11 @@ var DiffCalculator = class {
|
|
|
163
242
|
return this.calculate(Array.from(oldMap.values()), Array.from(newMap.values()));
|
|
164
243
|
}
|
|
165
244
|
/**
|
|
166
|
-
*
|
|
245
|
+
* Calculates differences based on a sequence of change records.
|
|
246
|
+
*
|
|
247
|
+
* @param baseEntries - The base set of sitemap entries.
|
|
248
|
+
* @param changes - An array of change records to apply to the base set.
|
|
249
|
+
* @returns A DiffResult comparing the base set with the applied changes.
|
|
167
250
|
*/
|
|
168
251
|
calculateFromChanges(baseEntries, changes) {
|
|
169
252
|
const entryMap = /* @__PURE__ */ new Map();
|
|
@@ -183,7 +266,11 @@ var DiffCalculator = class {
|
|
|
183
266
|
return this.calculate(baseEntries, newEntries);
|
|
184
267
|
}
|
|
185
268
|
/**
|
|
186
|
-
*
|
|
269
|
+
* Checks if a sitemap entry has changed by comparing its key properties.
|
|
270
|
+
*
|
|
271
|
+
* @param oldEntry - The previous sitemap entry.
|
|
272
|
+
* @param newEntry - The current sitemap entry.
|
|
273
|
+
* @returns True if the entry has changed, false otherwise.
|
|
187
274
|
*/
|
|
188
275
|
hasChanged(oldEntry, newEntry) {
|
|
189
276
|
if (oldEntry.lastmod !== newEntry.lastmod) {
|
|
@@ -204,55 +291,89 @@ var DiffCalculator = class {
|
|
|
204
291
|
}
|
|
205
292
|
};
|
|
206
293
|
|
|
294
|
+
// src/utils/Mutex.ts
|
|
295
|
+
var Mutex = class {
|
|
296
|
+
queue = Promise.resolve();
|
|
297
|
+
/**
|
|
298
|
+
* Executes a function exclusively, ensuring no other task can run it concurrently.
|
|
299
|
+
*
|
|
300
|
+
* @param fn - The async function to execute.
|
|
301
|
+
* @returns A promise resolving to the result of the function.
|
|
302
|
+
*/
|
|
303
|
+
async runExclusive(fn) {
|
|
304
|
+
const next = this.queue.then(() => fn());
|
|
305
|
+
this.queue = next.then(
|
|
306
|
+
() => {
|
|
307
|
+
},
|
|
308
|
+
() => {
|
|
309
|
+
}
|
|
310
|
+
);
|
|
311
|
+
return next;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
207
315
|
// src/core/ShadowProcessor.ts
|
|
208
316
|
var ShadowProcessor = class {
|
|
209
317
|
options;
|
|
210
318
|
shadowId;
|
|
211
319
|
operations = [];
|
|
320
|
+
mutex = new Mutex();
|
|
212
321
|
constructor(options) {
|
|
213
322
|
this.options = options;
|
|
214
|
-
this.shadowId = `shadow-${Date.now()}-${
|
|
323
|
+
this.shadowId = `shadow-${Date.now()}-${crypto.randomUUID()}`;
|
|
215
324
|
}
|
|
216
325
|
/**
|
|
217
|
-
*
|
|
326
|
+
* Adds a single file write operation to the current shadow session.
|
|
327
|
+
*
|
|
328
|
+
* If shadow processing is disabled, the file is written directly to the
|
|
329
|
+
* final destination in storage. Otherwise, it is written to the shadow staging area.
|
|
330
|
+
*
|
|
331
|
+
* @param operation - The shadow write operation details.
|
|
218
332
|
*/
|
|
219
333
|
async addOperation(operation) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
334
|
+
return this.mutex.runExclusive(async () => {
|
|
335
|
+
if (!this.options.enabled) {
|
|
336
|
+
await this.options.storage.write(operation.filename, operation.content);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this.operations.push({
|
|
340
|
+
...operation,
|
|
341
|
+
shadowId: operation.shadowId || this.shadowId
|
|
342
|
+
});
|
|
343
|
+
if (this.options.storage.writeShadow) {
|
|
344
|
+
await this.options.storage.writeShadow(operation.filename, operation.content, this.shadowId);
|
|
345
|
+
} else {
|
|
346
|
+
await this.options.storage.write(operation.filename, operation.content);
|
|
347
|
+
}
|
|
227
348
|
});
|
|
228
|
-
if (this.options.storage.writeShadow) {
|
|
229
|
-
await this.options.storage.writeShadow(operation.filename, operation.content, this.shadowId);
|
|
230
|
-
} else {
|
|
231
|
-
await this.options.storage.write(operation.filename, operation.content);
|
|
232
|
-
}
|
|
233
349
|
}
|
|
234
350
|
/**
|
|
235
|
-
*
|
|
351
|
+
* Commits all staged shadow operations to the final production location.
|
|
352
|
+
*
|
|
353
|
+
* Depending on the `mode`, this will either perform an atomic swap of all files
|
|
354
|
+
* or commit each file individually (potentially creating new versions).
|
|
236
355
|
*/
|
|
237
356
|
async commit() {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (this.options.mode === "atomic") {
|
|
242
|
-
if (this.options.storage.commitShadow) {
|
|
243
|
-
await this.options.storage.commitShadow(this.shadowId);
|
|
357
|
+
return this.mutex.runExclusive(async () => {
|
|
358
|
+
if (!this.options.enabled) {
|
|
359
|
+
return;
|
|
244
360
|
}
|
|
245
|
-
|
|
246
|
-
for (const operation of this.operations) {
|
|
361
|
+
if (this.options.mode === "atomic") {
|
|
247
362
|
if (this.options.storage.commitShadow) {
|
|
248
|
-
await this.options.storage.commitShadow(
|
|
363
|
+
await this.options.storage.commitShadow(this.shadowId);
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
for (const operation of this.operations) {
|
|
367
|
+
if (this.options.storage.commitShadow) {
|
|
368
|
+
await this.options.storage.commitShadow(operation.shadowId || this.shadowId);
|
|
369
|
+
}
|
|
249
370
|
}
|
|
250
371
|
}
|
|
251
|
-
|
|
252
|
-
|
|
372
|
+
this.operations = [];
|
|
373
|
+
});
|
|
253
374
|
}
|
|
254
375
|
/**
|
|
255
|
-
*
|
|
376
|
+
* Cancels all staged shadow operations without committing them.
|
|
256
377
|
*/
|
|
257
378
|
async rollback() {
|
|
258
379
|
if (!this.options.enabled) {
|
|
@@ -261,13 +382,13 @@ var ShadowProcessor = class {
|
|
|
261
382
|
this.operations = [];
|
|
262
383
|
}
|
|
263
384
|
/**
|
|
264
|
-
*
|
|
385
|
+
* Returns the unique identifier for the current shadow session.
|
|
265
386
|
*/
|
|
266
387
|
getShadowId() {
|
|
267
388
|
return this.shadowId;
|
|
268
389
|
}
|
|
269
390
|
/**
|
|
270
|
-
*
|
|
391
|
+
* Returns an array of all staged shadow operations.
|
|
271
392
|
*/
|
|
272
393
|
getOperations() {
|
|
273
394
|
return [...this.operations];
|
|
@@ -284,6 +405,12 @@ var SitemapIndex = class {
|
|
|
284
405
|
this.options.baseUrl = this.options.baseUrl.slice(0, -1);
|
|
285
406
|
}
|
|
286
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Adds a single entry to the sitemap index.
|
|
410
|
+
*
|
|
411
|
+
* @param entry - A sitemap filename or a `SitemapIndexEntry` object.
|
|
412
|
+
* @returns The `SitemapIndex` instance for chaining.
|
|
413
|
+
*/
|
|
287
414
|
add(entry) {
|
|
288
415
|
if (typeof entry === "string") {
|
|
289
416
|
this.entries.push({ url: entry });
|
|
@@ -292,12 +419,23 @@ var SitemapIndex = class {
|
|
|
292
419
|
}
|
|
293
420
|
return this;
|
|
294
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Adds multiple entries to the sitemap index.
|
|
424
|
+
*
|
|
425
|
+
* @param entries - An array of sitemap filenames or `SitemapIndexEntry` objects.
|
|
426
|
+
* @returns The `SitemapIndex` instance for chaining.
|
|
427
|
+
*/
|
|
295
428
|
addAll(entries) {
|
|
296
429
|
for (const entry of entries) {
|
|
297
430
|
this.add(entry);
|
|
298
431
|
}
|
|
299
432
|
return this;
|
|
300
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* Generates the sitemap index XML content.
|
|
436
|
+
*
|
|
437
|
+
* @returns The complete XML string for the sitemap index.
|
|
438
|
+
*/
|
|
301
439
|
toXML() {
|
|
302
440
|
const { baseUrl, pretty } = this.options;
|
|
303
441
|
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -326,6 +464,9 @@ var SitemapIndex = class {
|
|
|
326
464
|
xml += `</sitemapindex>`;
|
|
327
465
|
return xml;
|
|
328
466
|
}
|
|
467
|
+
/**
|
|
468
|
+
* Escapes special XML characters in a string.
|
|
469
|
+
*/
|
|
329
470
|
escape(str) {
|
|
330
471
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
331
472
|
}
|
|
@@ -335,55 +476,137 @@ var SitemapIndex = class {
|
|
|
335
476
|
var SitemapStream = class {
|
|
336
477
|
options;
|
|
337
478
|
entries = [];
|
|
479
|
+
flags = {
|
|
480
|
+
hasImages: false,
|
|
481
|
+
hasVideos: false,
|
|
482
|
+
hasNews: false,
|
|
483
|
+
hasAlternates: false
|
|
484
|
+
};
|
|
338
485
|
constructor(options) {
|
|
339
486
|
this.options = { ...options };
|
|
340
487
|
if (this.options.baseUrl.endsWith("/")) {
|
|
341
488
|
this.options.baseUrl = this.options.baseUrl.slice(0, -1);
|
|
342
489
|
}
|
|
343
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* Adds a single entry to the sitemap stream.
|
|
493
|
+
*
|
|
494
|
+
* @param entry - A URL string or a complete `SitemapEntry` object.
|
|
495
|
+
* @returns The `SitemapStream` instance for chaining.
|
|
496
|
+
*/
|
|
344
497
|
add(entry) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
this.
|
|
498
|
+
const e = typeof entry === "string" ? { url: entry } : entry;
|
|
499
|
+
this.entries.push(e);
|
|
500
|
+
if (e.images && e.images.length > 0) {
|
|
501
|
+
this.flags.hasImages = true;
|
|
502
|
+
}
|
|
503
|
+
if (e.videos && e.videos.length > 0) {
|
|
504
|
+
this.flags.hasVideos = true;
|
|
505
|
+
}
|
|
506
|
+
if (e.news) {
|
|
507
|
+
this.flags.hasNews = true;
|
|
508
|
+
}
|
|
509
|
+
if (e.alternates && e.alternates.length > 0) {
|
|
510
|
+
this.flags.hasAlternates = true;
|
|
349
511
|
}
|
|
350
512
|
return this;
|
|
351
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* Adds multiple entries to the sitemap stream.
|
|
516
|
+
*
|
|
517
|
+
* @param entries - An array of URL strings or `SitemapEntry` objects.
|
|
518
|
+
* @returns The `SitemapStream` instance for chaining.
|
|
519
|
+
*/
|
|
352
520
|
addAll(entries) {
|
|
353
521
|
for (const entry of entries) {
|
|
354
522
|
this.add(entry);
|
|
355
523
|
}
|
|
356
524
|
return this;
|
|
357
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* Generates the sitemap XML content.
|
|
528
|
+
*
|
|
529
|
+
* Automatically includes the necessary XML namespaces for images, videos, news,
|
|
530
|
+
* and internationalization if the entries contain such metadata.
|
|
531
|
+
*
|
|
532
|
+
* @returns The complete XML string for the sitemap.
|
|
533
|
+
*/
|
|
358
534
|
toXML() {
|
|
535
|
+
const parts = [];
|
|
536
|
+
for (const chunk of this.toSyncIterable()) {
|
|
537
|
+
parts.push(chunk);
|
|
538
|
+
}
|
|
539
|
+
return parts.join("");
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* 以 AsyncGenerator 方式產生 XML 內容。
|
|
543
|
+
* 每次 yield 一個邏輯區塊,適合串流寫入場景,可減少記憶體峰值。
|
|
544
|
+
*
|
|
545
|
+
* @returns AsyncGenerator 產生 XML 字串片段
|
|
546
|
+
*
|
|
547
|
+
* @example
|
|
548
|
+
* ```typescript
|
|
549
|
+
* const stream = new SitemapStream({ baseUrl: 'https://example.com' })
|
|
550
|
+
* stream.add({ url: '/page1' })
|
|
551
|
+
* stream.add({ url: '/page2' })
|
|
552
|
+
*
|
|
553
|
+
* for await (const chunk of stream.toAsyncIterable()) {
|
|
554
|
+
* process.stdout.write(chunk)
|
|
555
|
+
* }
|
|
556
|
+
* ```
|
|
557
|
+
*
|
|
558
|
+
* @since 3.1.0
|
|
559
|
+
*/
|
|
560
|
+
async *toAsyncIterable() {
|
|
561
|
+
yield '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
562
|
+
yield this.buildUrlsetOpenTag();
|
|
359
563
|
const { baseUrl, pretty } = this.options;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
xml += `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"`;
|
|
363
|
-
if (this.hasImages()) {
|
|
364
|
-
xml += ` xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"`;
|
|
564
|
+
for (const entry of this.entries) {
|
|
565
|
+
yield this.renderUrl(entry, baseUrl, pretty);
|
|
365
566
|
}
|
|
366
|
-
|
|
367
|
-
|
|
567
|
+
yield "</urlset>";
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* 同步版本的 iterable,供 toXML() 使用。
|
|
571
|
+
*/
|
|
572
|
+
*toSyncIterable() {
|
|
573
|
+
yield '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
574
|
+
yield this.buildUrlsetOpenTag();
|
|
575
|
+
const { baseUrl, pretty } = this.options;
|
|
576
|
+
for (const entry of this.entries) {
|
|
577
|
+
yield this.renderUrl(entry, baseUrl, pretty);
|
|
368
578
|
}
|
|
369
|
-
|
|
370
|
-
|
|
579
|
+
yield "</urlset>";
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* 建立 urlset 開標籤與所有必要的 XML 命名空間。
|
|
583
|
+
*/
|
|
584
|
+
buildUrlsetOpenTag() {
|
|
585
|
+
const parts = [];
|
|
586
|
+
parts.push('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"');
|
|
587
|
+
if (this.flags.hasImages) {
|
|
588
|
+
parts.push(' xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"');
|
|
371
589
|
}
|
|
372
|
-
if (this.
|
|
373
|
-
|
|
590
|
+
if (this.flags.hasVideos) {
|
|
591
|
+
parts.push(' xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"');
|
|
374
592
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
for (const entry of this.entries) {
|
|
378
|
-
xml += this.renderUrl(entry, baseUrl, pretty);
|
|
593
|
+
if (this.flags.hasNews) {
|
|
594
|
+
parts.push(' xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"');
|
|
379
595
|
}
|
|
380
|
-
|
|
381
|
-
|
|
596
|
+
if (this.flags.hasAlternates) {
|
|
597
|
+
parts.push(' xmlns:xhtml="http://www.w3.org/1999/xhtml"');
|
|
598
|
+
}
|
|
599
|
+
parts.push(">\n");
|
|
600
|
+
return parts.join("");
|
|
382
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Renders a single sitemap entry into its XML representation.
|
|
604
|
+
*/
|
|
383
605
|
renderUrl(entry, baseUrl, pretty) {
|
|
384
606
|
const indent = pretty ? " " : "";
|
|
385
607
|
const subIndent = pretty ? " " : "";
|
|
386
608
|
const nl = pretty ? "\n" : "";
|
|
609
|
+
const parts = [];
|
|
387
610
|
let loc = entry.url;
|
|
388
611
|
if (!loc.startsWith("http")) {
|
|
389
612
|
if (!loc.startsWith("/")) {
|
|
@@ -391,17 +614,17 @@ var SitemapStream = class {
|
|
|
391
614
|
}
|
|
392
615
|
loc = baseUrl + loc;
|
|
393
616
|
}
|
|
394
|
-
|
|
395
|
-
|
|
617
|
+
parts.push(`${indent}<url>${nl}`);
|
|
618
|
+
parts.push(`${subIndent}<loc>${this.escape(loc)}</loc>${nl}`);
|
|
396
619
|
if (entry.lastmod) {
|
|
397
620
|
const date = entry.lastmod instanceof Date ? entry.lastmod : new Date(entry.lastmod);
|
|
398
|
-
|
|
621
|
+
parts.push(`${subIndent}<lastmod>${date.toISOString().split("T")[0]}</lastmod>${nl}`);
|
|
399
622
|
}
|
|
400
623
|
if (entry.changefreq) {
|
|
401
|
-
|
|
624
|
+
parts.push(`${subIndent}<changefreq>${entry.changefreq}</changefreq>${nl}`);
|
|
402
625
|
}
|
|
403
626
|
if (entry.priority !== void 0) {
|
|
404
|
-
|
|
627
|
+
parts.push(`${subIndent}<priority>${entry.priority.toFixed(1)}</priority>${nl}`);
|
|
405
628
|
}
|
|
406
629
|
if (entry.alternates) {
|
|
407
630
|
for (const alt of entry.alternates) {
|
|
@@ -412,7 +635,9 @@ var SitemapStream = class {
|
|
|
412
635
|
}
|
|
413
636
|
altLoc = baseUrl + altLoc;
|
|
414
637
|
}
|
|
415
|
-
|
|
638
|
+
parts.push(
|
|
639
|
+
`${subIndent}<xhtml:link rel="alternate" hreflang="${alt.lang}" href="${this.escape(altLoc)}"/>${nl}`
|
|
640
|
+
);
|
|
416
641
|
}
|
|
417
642
|
}
|
|
418
643
|
if (entry.redirect?.canonical) {
|
|
@@ -423,10 +648,14 @@ var SitemapStream = class {
|
|
|
423
648
|
}
|
|
424
649
|
canonicalUrl = baseUrl + canonicalUrl;
|
|
425
650
|
}
|
|
426
|
-
|
|
651
|
+
parts.push(
|
|
652
|
+
`${subIndent}<xhtml:link rel="canonical" href="${this.escape(canonicalUrl)}"/>${nl}`
|
|
653
|
+
);
|
|
427
654
|
}
|
|
428
655
|
if (entry.redirect && !entry.redirect.canonical) {
|
|
429
|
-
|
|
656
|
+
parts.push(
|
|
657
|
+
`${subIndent}<!-- Redirect: ${entry.redirect.from} \u2192 ${entry.redirect.to} (${entry.redirect.type}) -->${nl}`
|
|
658
|
+
);
|
|
430
659
|
}
|
|
431
660
|
if (entry.images) {
|
|
432
661
|
for (const img of entry.images) {
|
|
@@ -437,91 +666,118 @@ var SitemapStream = class {
|
|
|
437
666
|
}
|
|
438
667
|
loc2 = baseUrl + loc2;
|
|
439
668
|
}
|
|
440
|
-
|
|
441
|
-
|
|
669
|
+
parts.push(`${subIndent}<image:image>${nl}`);
|
|
670
|
+
parts.push(`${subIndent} <image:loc>${this.escape(loc2)}</image:loc>${nl}`);
|
|
442
671
|
if (img.title) {
|
|
443
|
-
|
|
672
|
+
parts.push(`${subIndent} <image:title>${this.escape(img.title)}</image:title>${nl}`);
|
|
444
673
|
}
|
|
445
674
|
if (img.caption) {
|
|
446
|
-
|
|
675
|
+
parts.push(
|
|
676
|
+
`${subIndent} <image:caption>${this.escape(img.caption)}</image:caption>${nl}`
|
|
677
|
+
);
|
|
447
678
|
}
|
|
448
679
|
if (img.geo_location) {
|
|
449
|
-
|
|
680
|
+
parts.push(
|
|
681
|
+
`${subIndent} <image:geo_location>${this.escape(img.geo_location)}</image:geo_location>${nl}`
|
|
682
|
+
);
|
|
450
683
|
}
|
|
451
684
|
if (img.license) {
|
|
452
|
-
|
|
685
|
+
parts.push(
|
|
686
|
+
`${subIndent} <image:license>${this.escape(img.license)}</image:license>${nl}`
|
|
687
|
+
);
|
|
453
688
|
}
|
|
454
|
-
|
|
689
|
+
parts.push(`${subIndent}</image:image>${nl}`);
|
|
455
690
|
}
|
|
456
691
|
}
|
|
457
692
|
if (entry.videos) {
|
|
458
693
|
for (const video of entry.videos) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
694
|
+
parts.push(`${subIndent}<video:video>${nl}`);
|
|
695
|
+
parts.push(
|
|
696
|
+
`${subIndent} <video:thumbnail_loc>${this.escape(video.thumbnail_loc)}</video:thumbnail_loc>${nl}`
|
|
697
|
+
);
|
|
698
|
+
parts.push(`${subIndent} <video:title>${this.escape(video.title)}</video:title>${nl}`);
|
|
699
|
+
parts.push(
|
|
700
|
+
`${subIndent} <video:description>${this.escape(video.description)}</video:description>${nl}`
|
|
701
|
+
);
|
|
463
702
|
if (video.content_loc) {
|
|
464
|
-
|
|
703
|
+
parts.push(
|
|
704
|
+
`${subIndent} <video:content_loc>${this.escape(video.content_loc)}</video:content_loc>${nl}`
|
|
705
|
+
);
|
|
465
706
|
}
|
|
466
707
|
if (video.player_loc) {
|
|
467
|
-
|
|
708
|
+
parts.push(
|
|
709
|
+
`${subIndent} <video:player_loc>${this.escape(video.player_loc)}</video:player_loc>${nl}`
|
|
710
|
+
);
|
|
468
711
|
}
|
|
469
712
|
if (video.duration) {
|
|
470
|
-
|
|
713
|
+
parts.push(`${subIndent} <video:duration>${video.duration}</video:duration>${nl}`);
|
|
471
714
|
}
|
|
472
715
|
if (video.view_count) {
|
|
473
|
-
|
|
716
|
+
parts.push(`${subIndent} <video:view_count>${video.view_count}</video:view_count>${nl}`);
|
|
474
717
|
}
|
|
475
718
|
if (video.publication_date) {
|
|
476
719
|
const pubDate = video.publication_date instanceof Date ? video.publication_date : new Date(video.publication_date);
|
|
477
|
-
|
|
720
|
+
parts.push(
|
|
721
|
+
`${subIndent} <video:publication_date>${pubDate.toISOString()}</video:publication_date>${nl}`
|
|
722
|
+
);
|
|
478
723
|
}
|
|
479
724
|
if (video.family_friendly) {
|
|
480
|
-
|
|
725
|
+
parts.push(
|
|
726
|
+
`${subIndent} <video:family_friendly>${video.family_friendly}</video:family_friendly>${nl}`
|
|
727
|
+
);
|
|
481
728
|
}
|
|
482
729
|
if (video.tag) {
|
|
483
730
|
for (const tag of video.tag) {
|
|
484
|
-
|
|
731
|
+
parts.push(`${subIndent} <video:tag>${this.escape(tag)}</video:tag>${nl}`);
|
|
485
732
|
}
|
|
486
733
|
}
|
|
487
|
-
|
|
734
|
+
parts.push(`${subIndent}</video:video>${nl}`);
|
|
488
735
|
}
|
|
489
736
|
}
|
|
490
737
|
if (entry.news) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
738
|
+
parts.push(`${subIndent}<news:news>${nl}`);
|
|
739
|
+
parts.push(`${subIndent} <news:publication>${nl}`);
|
|
740
|
+
parts.push(
|
|
741
|
+
`${subIndent} <news:name>${this.escape(entry.news.publication.name)}</news:name>${nl}`
|
|
742
|
+
);
|
|
743
|
+
parts.push(
|
|
744
|
+
`${subIndent} <news:language>${this.escape(entry.news.publication.language)}</news:language>${nl}`
|
|
745
|
+
);
|
|
746
|
+
parts.push(`${subIndent} </news:publication>${nl}`);
|
|
496
747
|
const pubDate = entry.news.publication_date instanceof Date ? entry.news.publication_date : new Date(entry.news.publication_date);
|
|
497
|
-
|
|
498
|
-
|
|
748
|
+
parts.push(
|
|
749
|
+
`${subIndent} <news:publication_date>${pubDate.toISOString()}</news:publication_date>${nl}`
|
|
750
|
+
);
|
|
751
|
+
parts.push(`${subIndent} <news:title>${this.escape(entry.news.title)}</news:title>${nl}`);
|
|
499
752
|
if (entry.news.genres) {
|
|
500
|
-
|
|
753
|
+
parts.push(
|
|
754
|
+
`${subIndent} <news:genres>${this.escape(entry.news.genres)}</news:genres>${nl}`
|
|
755
|
+
);
|
|
501
756
|
}
|
|
502
757
|
if (entry.news.keywords) {
|
|
503
|
-
|
|
758
|
+
parts.push(
|
|
759
|
+
`${subIndent} <news:keywords>${entry.news.keywords.map((k) => this.escape(k)).join(", ")}</news:keywords>${nl}`
|
|
760
|
+
);
|
|
504
761
|
}
|
|
505
|
-
|
|
762
|
+
parts.push(`${subIndent}</news:news>${nl}`);
|
|
506
763
|
}
|
|
507
|
-
|
|
508
|
-
return
|
|
509
|
-
}
|
|
510
|
-
hasImages() {
|
|
511
|
-
return this.entries.some((e) => e.images && e.images.length > 0);
|
|
512
|
-
}
|
|
513
|
-
hasVideos() {
|
|
514
|
-
return this.entries.some((e) => e.videos && e.videos.length > 0);
|
|
515
|
-
}
|
|
516
|
-
hasNews() {
|
|
517
|
-
return this.entries.some((e) => !!e.news);
|
|
518
|
-
}
|
|
519
|
-
hasAlternates() {
|
|
520
|
-
return this.entries.some((e) => e.alternates && e.alternates.length > 0);
|
|
764
|
+
parts.push(`${indent}</url>${nl}`);
|
|
765
|
+
return parts.join("");
|
|
521
766
|
}
|
|
767
|
+
/**
|
|
768
|
+
* Escapes special XML characters in a string.
|
|
769
|
+
*/
|
|
522
770
|
escape(str) {
|
|
523
771
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
524
772
|
}
|
|
773
|
+
/**
|
|
774
|
+
* Returns all entries currently in the stream.
|
|
775
|
+
*
|
|
776
|
+
* @returns An array of `SitemapEntry` objects.
|
|
777
|
+
*/
|
|
778
|
+
getEntries() {
|
|
779
|
+
return this.entries;
|
|
780
|
+
}
|
|
525
781
|
};
|
|
526
782
|
|
|
527
783
|
// src/core/SitemapGenerator.ts
|
|
@@ -542,6 +798,12 @@ var SitemapGenerator = class {
|
|
|
542
798
|
});
|
|
543
799
|
}
|
|
544
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* Orchestrates the sitemap generation process.
|
|
803
|
+
*
|
|
804
|
+
* This method scans all providers, handles sharding, generates the XML files,
|
|
805
|
+
* and optionally creates a sitemap index and manifest.
|
|
806
|
+
*/
|
|
545
807
|
async run() {
|
|
546
808
|
let shardIndex = 1;
|
|
547
809
|
let currentCount = 0;
|
|
@@ -553,18 +815,29 @@ var SitemapGenerator = class {
|
|
|
553
815
|
baseUrl: this.options.baseUrl,
|
|
554
816
|
pretty: this.options.pretty
|
|
555
817
|
});
|
|
818
|
+
const shards = [];
|
|
556
819
|
let isMultiFile = false;
|
|
557
820
|
const flushShard = async () => {
|
|
558
821
|
isMultiFile = true;
|
|
559
822
|
const baseName = this.options.filename?.replace(/\.xml$/, "");
|
|
560
823
|
const filename = `${baseName}-${shardIndex}.xml`;
|
|
561
|
-
const
|
|
824
|
+
const entries = currentStream.getEntries();
|
|
825
|
+
let actualFilename;
|
|
562
826
|
if (this.shadowProcessor) {
|
|
563
|
-
|
|
827
|
+
actualFilename = this.options.compression?.enabled ? toGzipFilename(filename) : filename;
|
|
828
|
+
const xml = currentStream.toXML();
|
|
829
|
+
await this.shadowProcessor.addOperation({ filename: actualFilename, content: xml });
|
|
564
830
|
} else {
|
|
565
|
-
await this.
|
|
831
|
+
actualFilename = await this.writeSitemap(currentStream, filename);
|
|
566
832
|
}
|
|
567
|
-
|
|
833
|
+
shards.push({
|
|
834
|
+
filename: actualFilename,
|
|
835
|
+
from: this.normalizeUrl(entries[0].url),
|
|
836
|
+
to: this.normalizeUrl(entries[entries.length - 1].url),
|
|
837
|
+
count: entries.length,
|
|
838
|
+
lastmod: /* @__PURE__ */ new Date()
|
|
839
|
+
});
|
|
840
|
+
const url = this.options.storage.getUrl(actualFilename);
|
|
568
841
|
index.add({
|
|
569
842
|
url,
|
|
570
843
|
lastmod: /* @__PURE__ */ new Date()
|
|
@@ -596,16 +869,51 @@ var SitemapGenerator = class {
|
|
|
596
869
|
}
|
|
597
870
|
}
|
|
598
871
|
}
|
|
872
|
+
const writeManifest = async () => {
|
|
873
|
+
if (!this.options.generateManifest) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const manifest = {
|
|
877
|
+
version: 1,
|
|
878
|
+
generatedAt: /* @__PURE__ */ new Date(),
|
|
879
|
+
baseUrl: this.options.baseUrl,
|
|
880
|
+
maxEntriesPerShard: this.options.maxEntriesPerFile,
|
|
881
|
+
sort: "url-lex",
|
|
882
|
+
shards
|
|
883
|
+
};
|
|
884
|
+
const manifestFilename = this.options.filename?.replace(/\.xml$/, "-manifest.json") || "sitemap-manifest.json";
|
|
885
|
+
const content = JSON.stringify(manifest, null, this.options.pretty ? 2 : 0);
|
|
886
|
+
if (this.shadowProcessor) {
|
|
887
|
+
await this.shadowProcessor.addOperation({ filename: manifestFilename, content });
|
|
888
|
+
} else {
|
|
889
|
+
await this.options.storage.write(manifestFilename, content);
|
|
890
|
+
}
|
|
891
|
+
};
|
|
599
892
|
if (!isMultiFile) {
|
|
600
|
-
const
|
|
893
|
+
const entries = currentStream.getEntries();
|
|
894
|
+
let actualFilename;
|
|
601
895
|
if (this.shadowProcessor) {
|
|
896
|
+
actualFilename = this.options.compression?.enabled ? toGzipFilename(this.options.filename) : this.options.filename;
|
|
897
|
+
const xml = currentStream.toXML();
|
|
602
898
|
await this.shadowProcessor.addOperation({
|
|
603
|
-
filename:
|
|
899
|
+
filename: actualFilename,
|
|
604
900
|
content: xml
|
|
605
901
|
});
|
|
902
|
+
} else {
|
|
903
|
+
actualFilename = await this.writeSitemap(currentStream, this.options.filename);
|
|
904
|
+
}
|
|
905
|
+
shards.push({
|
|
906
|
+
filename: actualFilename,
|
|
907
|
+
from: entries[0] ? this.normalizeUrl(entries[0].url) : "",
|
|
908
|
+
to: entries[entries.length - 1] ? this.normalizeUrl(entries[entries.length - 1].url) : "",
|
|
909
|
+
count: entries.length,
|
|
910
|
+
lastmod: /* @__PURE__ */ new Date()
|
|
911
|
+
});
|
|
912
|
+
if (this.shadowProcessor) {
|
|
913
|
+
await writeManifest();
|
|
606
914
|
await this.shadowProcessor.commit();
|
|
607
915
|
} else {
|
|
608
|
-
await
|
|
916
|
+
await writeManifest();
|
|
609
917
|
}
|
|
610
918
|
return;
|
|
611
919
|
}
|
|
@@ -618,35 +926,203 @@ var SitemapGenerator = class {
|
|
|
618
926
|
filename: this.options.filename,
|
|
619
927
|
content: indexXml
|
|
620
928
|
});
|
|
929
|
+
await writeManifest();
|
|
621
930
|
await this.shadowProcessor.commit();
|
|
622
931
|
} else {
|
|
623
932
|
await this.options.storage.write(this.options.filename, indexXml);
|
|
933
|
+
await writeManifest();
|
|
624
934
|
}
|
|
625
935
|
}
|
|
626
936
|
/**
|
|
627
|
-
*
|
|
937
|
+
* 統一的 sitemap 寫入方法,優先使用串流寫入以降低記憶體使用。
|
|
938
|
+
*
|
|
939
|
+
* @param stream - SitemapStream 實例
|
|
940
|
+
* @param filename - 檔案名稱(不含 .gz)
|
|
941
|
+
* @returns 實際寫入的檔名(可能包含 .gz)
|
|
942
|
+
* @since 3.1.0
|
|
943
|
+
*/
|
|
944
|
+
async writeSitemap(stream, filename) {
|
|
945
|
+
const { storage, compression } = this.options;
|
|
946
|
+
const compress = compression?.enabled ?? false;
|
|
947
|
+
if (storage.writeStream) {
|
|
948
|
+
await storage.writeStream(filename, stream.toAsyncIterable(), {
|
|
949
|
+
compress,
|
|
950
|
+
contentType: "application/xml"
|
|
951
|
+
});
|
|
952
|
+
} else {
|
|
953
|
+
const xml = stream.toXML();
|
|
954
|
+
if (compress) {
|
|
955
|
+
const buffer = await compressToBuffer(
|
|
956
|
+
(async function* () {
|
|
957
|
+
yield xml;
|
|
958
|
+
})(),
|
|
959
|
+
{ level: compression?.level }
|
|
960
|
+
);
|
|
961
|
+
await storage.write(toGzipFilename(filename), buffer.toString("base64"));
|
|
962
|
+
} else {
|
|
963
|
+
await storage.write(filename, xml);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
return compress ? toGzipFilename(filename) : filename;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Normalizes a URL to an absolute URL using the base URL.
|
|
970
|
+
*/
|
|
971
|
+
normalizeUrl(url) {
|
|
972
|
+
if (url.startsWith("http")) {
|
|
973
|
+
return url;
|
|
974
|
+
}
|
|
975
|
+
const { baseUrl } = this.options;
|
|
976
|
+
const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
977
|
+
const normalizedPath = url.startsWith("/") ? url : `/${url}`;
|
|
978
|
+
return normalizedBase + normalizedPath;
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Returns the shadow processor instance if enabled.
|
|
628
982
|
*/
|
|
629
983
|
getShadowProcessor() {
|
|
630
984
|
return this.shadowProcessor;
|
|
631
985
|
}
|
|
632
986
|
};
|
|
633
987
|
|
|
988
|
+
// src/core/SitemapParser.ts
|
|
989
|
+
var SitemapParser = class {
|
|
990
|
+
/**
|
|
991
|
+
* Parses a sitemap XML string into an array of entries.
|
|
992
|
+
*
|
|
993
|
+
* @param xml - The raw sitemap XML content.
|
|
994
|
+
* @returns An array of `SitemapEntry` objects.
|
|
995
|
+
*/
|
|
996
|
+
static parse(xml) {
|
|
997
|
+
const entries = [];
|
|
998
|
+
const urlRegex = /<url>([\s\S]*?)<\/url>/g;
|
|
999
|
+
let match;
|
|
1000
|
+
while ((match = urlRegex.exec(xml)) !== null) {
|
|
1001
|
+
const entry = this.parseEntry(match[1]);
|
|
1002
|
+
if (entry) {
|
|
1003
|
+
entries.push(entry);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return entries;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Parses a sitemap XML stream into an async iterable of entries.
|
|
1010
|
+
*
|
|
1011
|
+
* Useful for large sitemap files that should not be fully loaded into memory.
|
|
1012
|
+
*
|
|
1013
|
+
* @param stream - An async iterable of XML chunks.
|
|
1014
|
+
* @returns An async iterable of `SitemapEntry` objects.
|
|
1015
|
+
*/
|
|
1016
|
+
static async *parseStream(stream) {
|
|
1017
|
+
let buffer = "";
|
|
1018
|
+
const urlRegex = /<url>([\s\S]*?)<\/url>/g;
|
|
1019
|
+
for await (const chunk of stream) {
|
|
1020
|
+
buffer += chunk;
|
|
1021
|
+
let match;
|
|
1022
|
+
while ((match = urlRegex.exec(buffer)) !== null) {
|
|
1023
|
+
const entry = this.parseEntry(match[1]);
|
|
1024
|
+
if (entry) {
|
|
1025
|
+
yield entry;
|
|
1026
|
+
}
|
|
1027
|
+
buffer = buffer.slice(match.index + match[0].length);
|
|
1028
|
+
urlRegex.lastIndex = 0;
|
|
1029
|
+
}
|
|
1030
|
+
if (buffer.length > 1024 * 1024) {
|
|
1031
|
+
const lastUrlStart = buffer.lastIndexOf("<url>");
|
|
1032
|
+
buffer = lastUrlStart !== -1 ? buffer.slice(lastUrlStart) : "";
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Parses a single `<url>` tag content into a `SitemapEntry`.
|
|
1038
|
+
*/
|
|
1039
|
+
static parseEntry(urlContent) {
|
|
1040
|
+
const entry = { url: "" };
|
|
1041
|
+
const locMatch = /<loc>(.*?)<\/loc>/.exec(urlContent);
|
|
1042
|
+
if (locMatch) {
|
|
1043
|
+
entry.url = this.unescape(locMatch[1]);
|
|
1044
|
+
} else {
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
const lastmodMatch = /<lastmod>(.*?)<\/lastmod>/.exec(urlContent);
|
|
1048
|
+
if (lastmodMatch) {
|
|
1049
|
+
entry.lastmod = new Date(lastmodMatch[1]);
|
|
1050
|
+
}
|
|
1051
|
+
const priorityMatch = /<priority>(.*?)<\/priority>/.exec(urlContent);
|
|
1052
|
+
if (priorityMatch) {
|
|
1053
|
+
entry.priority = parseFloat(priorityMatch[1]);
|
|
1054
|
+
}
|
|
1055
|
+
const changefreqMatch = /<changefreq>(.*?)<\/changefreq>/.exec(urlContent);
|
|
1056
|
+
if (changefreqMatch) {
|
|
1057
|
+
entry.changefreq = changefreqMatch[1];
|
|
1058
|
+
}
|
|
1059
|
+
return entry;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Parses a sitemap index XML string into an array of sitemap URLs.
|
|
1063
|
+
*
|
|
1064
|
+
* @param xml - The raw sitemap index XML content.
|
|
1065
|
+
* @returns An array of sub-sitemap URLs.
|
|
1066
|
+
*/
|
|
1067
|
+
static parseIndex(xml) {
|
|
1068
|
+
const urls = [];
|
|
1069
|
+
const sitemapRegex = /<sitemap>([\s\S]*?)<\/sitemap>/g;
|
|
1070
|
+
let match;
|
|
1071
|
+
while ((match = sitemapRegex.exec(xml)) !== null) {
|
|
1072
|
+
const content = match[1];
|
|
1073
|
+
const locMatch = /<loc>(.*?)<\/loc>/.exec(content);
|
|
1074
|
+
if (locMatch) {
|
|
1075
|
+
urls.push(this.unescape(locMatch[1]));
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return urls;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Unescapes special XML entities in a string.
|
|
1082
|
+
*/
|
|
1083
|
+
static unescape(str) {
|
|
1084
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
|
|
634
1088
|
// src/core/IncrementalGenerator.ts
|
|
635
1089
|
var IncrementalGenerator = class {
|
|
636
1090
|
options;
|
|
637
1091
|
changeTracker;
|
|
638
1092
|
diffCalculator;
|
|
639
1093
|
generator;
|
|
1094
|
+
mutex = new Mutex();
|
|
640
1095
|
constructor(options) {
|
|
641
|
-
this.options =
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
1096
|
+
this.options = {
|
|
1097
|
+
autoTrack: true,
|
|
1098
|
+
generateManifest: true,
|
|
1099
|
+
...options
|
|
1100
|
+
};
|
|
1101
|
+
this.changeTracker = this.options.changeTracker;
|
|
1102
|
+
this.diffCalculator = this.options.diffCalculator || new DiffCalculator();
|
|
1103
|
+
this.generator = new SitemapGenerator(this.options);
|
|
645
1104
|
}
|
|
646
1105
|
/**
|
|
647
|
-
*
|
|
1106
|
+
* Performs a full sitemap generation and optionally records all entries in the change tracker.
|
|
648
1107
|
*/
|
|
649
1108
|
async generateFull() {
|
|
1109
|
+
return this.mutex.runExclusive(() => this.performFullGeneration());
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Performs an incremental sitemap update based on changes recorded since a specific time.
|
|
1113
|
+
*
|
|
1114
|
+
* If the number of changes exceeds a certain threshold (e.g., 30% of total URLs),
|
|
1115
|
+
* a full generation is triggered instead to ensure consistency.
|
|
1116
|
+
*
|
|
1117
|
+
* @param since - Optional start date for the incremental update.
|
|
1118
|
+
*/
|
|
1119
|
+
async generateIncremental(since) {
|
|
1120
|
+
return this.mutex.runExclusive(() => this.performIncrementalGeneration(since));
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Internal implementation of full sitemap generation.
|
|
1124
|
+
*/
|
|
1125
|
+
async performFullGeneration() {
|
|
650
1126
|
await this.generator.run();
|
|
651
1127
|
if (this.options.autoTrack) {
|
|
652
1128
|
const { providers } = this.options;
|
|
@@ -665,50 +1141,149 @@ var IncrementalGenerator = class {
|
|
|
665
1141
|
}
|
|
666
1142
|
}
|
|
667
1143
|
/**
|
|
668
|
-
*
|
|
1144
|
+
* Internal implementation of incremental sitemap generation.
|
|
669
1145
|
*/
|
|
670
|
-
async
|
|
1146
|
+
async performIncrementalGeneration(since) {
|
|
671
1147
|
const changes = await this.changeTracker.getChanges(since);
|
|
672
1148
|
if (changes.length === 0) {
|
|
673
1149
|
return;
|
|
674
1150
|
}
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
1151
|
+
const manifest = await this.loadManifest();
|
|
1152
|
+
if (!manifest) {
|
|
1153
|
+
await this.performFullGeneration();
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
const totalCount = manifest.shards.reduce((acc, s) => acc + s.count, 0);
|
|
1157
|
+
const changeRatio = totalCount > 0 ? changes.length / totalCount : 1;
|
|
1158
|
+
if (changeRatio > 0.3) {
|
|
1159
|
+
await this.performFullGeneration();
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
const affectedShards = this.getAffectedShards(manifest, changes);
|
|
1163
|
+
if (affectedShards.size / manifest.shards.length > 0.5) {
|
|
1164
|
+
await this.performFullGeneration();
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
await this.updateShards(manifest, affectedShards);
|
|
678
1168
|
}
|
|
679
1169
|
/**
|
|
680
|
-
*
|
|
1170
|
+
* Normalizes a URL to an absolute URL using the base URL.
|
|
681
1171
|
*/
|
|
682
|
-
|
|
683
|
-
|
|
1172
|
+
normalizeUrl(url) {
|
|
1173
|
+
if (url.startsWith("http")) {
|
|
1174
|
+
return url;
|
|
1175
|
+
}
|
|
1176
|
+
const { baseUrl } = this.options;
|
|
1177
|
+
const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
1178
|
+
const normalizedPath = url.startsWith("/") ? url : `/${url}`;
|
|
1179
|
+
return normalizedBase + normalizedPath;
|
|
684
1180
|
}
|
|
685
1181
|
/**
|
|
686
|
-
*
|
|
1182
|
+
* Loads the sitemap shard manifest from storage.
|
|
687
1183
|
*/
|
|
688
|
-
async
|
|
689
|
-
|
|
1184
|
+
async loadManifest() {
|
|
1185
|
+
const filename = this.options.filename?.replace(/\.xml$/, "-manifest.json") || "sitemap-manifest.json";
|
|
1186
|
+
const content = await this.options.storage.read(filename);
|
|
1187
|
+
if (!content) {
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
return JSON.parse(content);
|
|
1192
|
+
} catch {
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
690
1195
|
}
|
|
691
1196
|
/**
|
|
692
|
-
*
|
|
1197
|
+
* Identifies which shards are affected by the given set of changes.
|
|
693
1198
|
*/
|
|
694
|
-
|
|
695
|
-
const
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1199
|
+
getAffectedShards(manifest, changes) {
|
|
1200
|
+
const affected = /* @__PURE__ */ new Map();
|
|
1201
|
+
for (const change of changes) {
|
|
1202
|
+
const normalizedUrl = this.normalizeUrl(change.url);
|
|
1203
|
+
let shard = manifest.shards.find((s) => {
|
|
1204
|
+
return normalizedUrl >= s.from && normalizedUrl <= s.to;
|
|
1205
|
+
});
|
|
1206
|
+
if (!shard) {
|
|
1207
|
+
shard = manifest.shards.find((s) => normalizedUrl <= s.to);
|
|
1208
|
+
if (!shard) {
|
|
1209
|
+
shard = manifest.shards[manifest.shards.length - 1];
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (shard) {
|
|
1213
|
+
const shardChanges = affected.get(shard.filename) || [];
|
|
1214
|
+
shardChanges.push(change);
|
|
1215
|
+
affected.set(shard.filename, shardChanges);
|
|
1216
|
+
}
|
|
701
1217
|
}
|
|
702
|
-
return
|
|
1218
|
+
return affected;
|
|
703
1219
|
}
|
|
704
1220
|
/**
|
|
705
|
-
*
|
|
1221
|
+
* Updates the affected shards in storage.
|
|
706
1222
|
*/
|
|
707
|
-
async
|
|
708
|
-
|
|
1223
|
+
async updateShards(manifest, affectedShards) {
|
|
1224
|
+
for (const [filename, shardChanges] of affectedShards) {
|
|
1225
|
+
const entries = [];
|
|
1226
|
+
const stream = await this.options.storage.readStream?.(filename);
|
|
1227
|
+
if (stream) {
|
|
1228
|
+
for await (const entry of SitemapParser.parseStream(stream)) {
|
|
1229
|
+
entries.push(entry);
|
|
1230
|
+
}
|
|
1231
|
+
} else {
|
|
1232
|
+
const xml = await this.options.storage.read(filename);
|
|
1233
|
+
if (!xml) {
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
entries.push(...SitemapParser.parse(xml));
|
|
1237
|
+
}
|
|
1238
|
+
const updatedEntries = this.applyChanges(entries, shardChanges);
|
|
1239
|
+
const outStream = new SitemapStream({
|
|
1240
|
+
baseUrl: this.options.baseUrl,
|
|
1241
|
+
pretty: this.options.pretty
|
|
1242
|
+
});
|
|
1243
|
+
outStream.addAll(updatedEntries);
|
|
1244
|
+
const newXml = outStream.toXML();
|
|
1245
|
+
await this.options.storage.write(filename, newXml);
|
|
1246
|
+
const shardInfo = manifest.shards.find((s) => s.filename === filename);
|
|
1247
|
+
if (shardInfo) {
|
|
1248
|
+
shardInfo.count = updatedEntries.length;
|
|
1249
|
+
shardInfo.lastmod = /* @__PURE__ */ new Date();
|
|
1250
|
+
shardInfo.from = this.normalizeUrl(updatedEntries[0].url);
|
|
1251
|
+
shardInfo.to = this.normalizeUrl(updatedEntries[updatedEntries.length - 1].url);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
const manifestFilename = this.options.filename?.replace(/\.xml$/, "-manifest.json") || "sitemap-manifest.json";
|
|
1255
|
+
await this.options.storage.write(
|
|
1256
|
+
manifestFilename,
|
|
1257
|
+
JSON.stringify(manifest, null, this.options.pretty ? 2 : 0)
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Applies changes to a set of sitemap entries and returns the updated, sorted list.
|
|
1262
|
+
*/
|
|
1263
|
+
applyChanges(entries, changes) {
|
|
1264
|
+
const entryMap = /* @__PURE__ */ new Map();
|
|
1265
|
+
for (const entry of entries) {
|
|
1266
|
+
entryMap.set(this.normalizeUrl(entry.url), entry);
|
|
1267
|
+
}
|
|
1268
|
+
for (const change of changes) {
|
|
1269
|
+
const normalizedUrl = this.normalizeUrl(change.url);
|
|
1270
|
+
if (change.type === "add" || change.type === "update") {
|
|
1271
|
+
if (change.entry) {
|
|
1272
|
+
entryMap.set(normalizedUrl, {
|
|
1273
|
+
...change.entry,
|
|
1274
|
+
url: normalizedUrl
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
} else if (change.type === "remove") {
|
|
1278
|
+
entryMap.delete(normalizedUrl);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return Array.from(entryMap.values()).sort(
|
|
1282
|
+
(a, b) => this.normalizeUrl(a.url).localeCompare(this.normalizeUrl(b.url))
|
|
1283
|
+
);
|
|
709
1284
|
}
|
|
710
1285
|
/**
|
|
711
|
-
*
|
|
1286
|
+
* Helper to convert an async iterable into an array.
|
|
712
1287
|
*/
|
|
713
1288
|
async toArray(iterable) {
|
|
714
1289
|
const array = [];
|
|
@@ -730,7 +1305,10 @@ var ProgressTracker = class {
|
|
|
730
1305
|
this.updateInterval = options.updateInterval || 1e3;
|
|
731
1306
|
}
|
|
732
1307
|
/**
|
|
733
|
-
*
|
|
1308
|
+
* Initializes progress tracking for a new job.
|
|
1309
|
+
*
|
|
1310
|
+
* @param jobId - Unique identifier for the generation job.
|
|
1311
|
+
* @param total - Total number of entries to be processed.
|
|
734
1312
|
*/
|
|
735
1313
|
async init(jobId, total) {
|
|
736
1314
|
this.currentProgress = {
|
|
@@ -744,7 +1322,13 @@ var ProgressTracker = class {
|
|
|
744
1322
|
await this.storage.set(jobId, this.currentProgress);
|
|
745
1323
|
}
|
|
746
1324
|
/**
|
|
747
|
-
*
|
|
1325
|
+
* Updates the current progress of the job.
|
|
1326
|
+
*
|
|
1327
|
+
* Updates are debounced and flushed to storage at regular intervals
|
|
1328
|
+
* specified by `updateInterval` to avoid excessive write operations.
|
|
1329
|
+
*
|
|
1330
|
+
* @param processed - Number of entries processed so far.
|
|
1331
|
+
* @param status - Optional new status for the job.
|
|
748
1332
|
*/
|
|
749
1333
|
async update(processed, status) {
|
|
750
1334
|
if (!this.currentProgress) {
|
|
@@ -766,7 +1350,7 @@ var ProgressTracker = class {
|
|
|
766
1350
|
}
|
|
767
1351
|
}
|
|
768
1352
|
/**
|
|
769
|
-
*
|
|
1353
|
+
* Marks the current job as successfully completed.
|
|
770
1354
|
*/
|
|
771
1355
|
async complete() {
|
|
772
1356
|
if (!this.currentProgress) {
|
|
@@ -779,7 +1363,9 @@ var ProgressTracker = class {
|
|
|
779
1363
|
this.stop();
|
|
780
1364
|
}
|
|
781
1365
|
/**
|
|
782
|
-
*
|
|
1366
|
+
* Marks the current job as failed with an error message.
|
|
1367
|
+
*
|
|
1368
|
+
* @param error - The error message describing why the job failed.
|
|
783
1369
|
*/
|
|
784
1370
|
async fail(error) {
|
|
785
1371
|
if (!this.currentProgress) {
|
|
@@ -792,7 +1378,7 @@ var ProgressTracker = class {
|
|
|
792
1378
|
this.stop();
|
|
793
1379
|
}
|
|
794
1380
|
/**
|
|
795
|
-
*
|
|
1381
|
+
* Flushes the current progress state to the storage backend.
|
|
796
1382
|
*/
|
|
797
1383
|
async flush() {
|
|
798
1384
|
if (!this.currentProgress) {
|
|
@@ -807,7 +1393,7 @@ var ProgressTracker = class {
|
|
|
807
1393
|
});
|
|
808
1394
|
}
|
|
809
1395
|
/**
|
|
810
|
-
*
|
|
1396
|
+
* Stops the periodic update timer.
|
|
811
1397
|
*/
|
|
812
1398
|
stop() {
|
|
813
1399
|
if (this.updateTimer) {
|
|
@@ -816,7 +1402,9 @@ var ProgressTracker = class {
|
|
|
816
1402
|
}
|
|
817
1403
|
}
|
|
818
1404
|
/**
|
|
819
|
-
*
|
|
1405
|
+
* Returns a copy of the current progress state.
|
|
1406
|
+
*
|
|
1407
|
+
* @returns The current SitemapProgress object, or null if no job is active.
|
|
820
1408
|
*/
|
|
821
1409
|
getCurrentProgress() {
|
|
822
1410
|
return this.currentProgress ? { ...this.currentProgress } : null;
|
|
@@ -854,6 +1442,12 @@ var GenerateSitemapJob = class extends Job {
|
|
|
854
1442
|
this.options = options;
|
|
855
1443
|
this.generator = new SitemapGenerator(options.generatorOptions);
|
|
856
1444
|
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Main entry point for the job execution.
|
|
1447
|
+
*
|
|
1448
|
+
* Orchestrates the full lifecycle of sitemap generation, including progress
|
|
1449
|
+
* initialization, generation, shadow commit, and error handling.
|
|
1450
|
+
*/
|
|
857
1451
|
async handle() {
|
|
858
1452
|
const { progressTracker, onComplete, onError } = this.options;
|
|
859
1453
|
try {
|
|
@@ -884,7 +1478,9 @@ var GenerateSitemapJob = class extends Job {
|
|
|
884
1478
|
}
|
|
885
1479
|
}
|
|
886
1480
|
/**
|
|
887
|
-
*
|
|
1481
|
+
* Calculates the total number of URL entries from all providers.
|
|
1482
|
+
*
|
|
1483
|
+
* @returns A promise resolving to the total entry count.
|
|
888
1484
|
*/
|
|
889
1485
|
async calculateTotal() {
|
|
890
1486
|
let total = 0;
|
|
@@ -899,30 +1495,580 @@ var GenerateSitemapJob = class extends Job {
|
|
|
899
1495
|
}
|
|
900
1496
|
}
|
|
901
1497
|
}
|
|
902
|
-
return total;
|
|
1498
|
+
return total;
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Performs sitemap generation while reporting progress to the tracker and callback.
|
|
1502
|
+
*/
|
|
1503
|
+
async generateWithProgress() {
|
|
1504
|
+
const { progressTracker, onProgress } = this.options;
|
|
1505
|
+
await this.generator.run();
|
|
1506
|
+
this.processedEntries = this.totalEntries;
|
|
1507
|
+
if (progressTracker) {
|
|
1508
|
+
await progressTracker.update(this.processedEntries, "processing");
|
|
1509
|
+
}
|
|
1510
|
+
if (onProgress) {
|
|
1511
|
+
onProgress({
|
|
1512
|
+
processed: this.processedEntries,
|
|
1513
|
+
total: this.totalEntries,
|
|
1514
|
+
percentage: this.totalEntries > 0 ? Math.round(this.processedEntries / this.totalEntries * 100) : 100
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
// src/locks/MemoryLock.ts
|
|
1521
|
+
var MemoryLock = class {
|
|
1522
|
+
/**
|
|
1523
|
+
* Internal map storing resource identifiers to their lock expiration timestamps.
|
|
1524
|
+
*
|
|
1525
|
+
* Keys represent unique resource identifiers (e.g., 'sitemap-generation').
|
|
1526
|
+
* Values are Unix timestamps in milliseconds representing when the lock expires.
|
|
1527
|
+
* Expired locks are automatically cleaned up during `acquire()` and `isLocked()` calls.
|
|
1528
|
+
*/
|
|
1529
|
+
locks = /* @__PURE__ */ new Map();
|
|
1530
|
+
/**
|
|
1531
|
+
* Attempts to acquire an exclusive lock on the specified resource.
|
|
1532
|
+
*
|
|
1533
|
+
* Uses a test-and-set approach: checks if the lock exists and is valid, then atomically
|
|
1534
|
+
* sets the lock if available. Expired locks are treated as available and automatically
|
|
1535
|
+
* replaced during acquisition.
|
|
1536
|
+
*
|
|
1537
|
+
* **Behavior:**
|
|
1538
|
+
* - Returns `true` if lock was successfully acquired
|
|
1539
|
+
* - Returns `false` if resource is already locked by another caller
|
|
1540
|
+
* - Automatically replaces expired locks (acts as self-healing mechanism)
|
|
1541
|
+
* - Lock automatically expires after TTL milliseconds
|
|
1542
|
+
*
|
|
1543
|
+
* **Race condition handling:**
|
|
1544
|
+
* Safe within a single process due to JavaScript's single-threaded event loop.
|
|
1545
|
+
* NOT safe across multiple processes or instances (use RedisLock for that).
|
|
1546
|
+
*
|
|
1547
|
+
* @param resource - Unique identifier for the resource to lock (e.g., 'sitemap-generation', 'blog-index').
|
|
1548
|
+
* Should be consistent across all callers attempting to lock the same resource.
|
|
1549
|
+
* @param ttl - Time-to-live in milliseconds. Lock automatically expires after this duration.
|
|
1550
|
+
* Recommended: 2-5x the expected operation duration to prevent premature expiration.
|
|
1551
|
+
* @returns Promise resolving to `true` if lock acquired, `false` if already locked.
|
|
1552
|
+
*
|
|
1553
|
+
* @example Preventing concurrent sitemap generation
|
|
1554
|
+
* ```typescript
|
|
1555
|
+
* const lock = new MemoryLock()
|
|
1556
|
+
* const acquired = await lock.acquire('sitemap-generation', 60000)
|
|
1557
|
+
*
|
|
1558
|
+
* if (!acquired) {
|
|
1559
|
+
* console.log('Another process is already generating the sitemap')
|
|
1560
|
+
* return new Response('Generation in progress', { status: 503 })
|
|
1561
|
+
* }
|
|
1562
|
+
*
|
|
1563
|
+
* try {
|
|
1564
|
+
* await generateSitemap()
|
|
1565
|
+
* } finally {
|
|
1566
|
+
* await lock.release('sitemap-generation')
|
|
1567
|
+
* }
|
|
1568
|
+
* ```
|
|
1569
|
+
*
|
|
1570
|
+
* @example Setting appropriate TTL
|
|
1571
|
+
* ```typescript
|
|
1572
|
+
* // For fast operations (< 1 second), use short TTL
|
|
1573
|
+
* await lock.acquire('cache-refresh', 5000)
|
|
1574
|
+
*
|
|
1575
|
+
* // For slow operations (minutes), use longer TTL
|
|
1576
|
+
* await lock.acquire('full-reindex', 300000) // 5 minutes
|
|
1577
|
+
* ```
|
|
1578
|
+
*/
|
|
1579
|
+
async acquire(resource, ttl) {
|
|
1580
|
+
const now = Date.now();
|
|
1581
|
+
const expiresAt = this.locks.get(resource);
|
|
1582
|
+
if (expiresAt && expiresAt > now) {
|
|
1583
|
+
return false;
|
|
1584
|
+
}
|
|
1585
|
+
this.locks.set(resource, now + ttl);
|
|
1586
|
+
return true;
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Releases the lock on the specified resource, allowing others to acquire it.
|
|
1590
|
+
*
|
|
1591
|
+
* Immediately removes the lock from memory without any ownership validation.
|
|
1592
|
+
* Unlike RedisLock, this does NOT verify that the caller is the lock owner,
|
|
1593
|
+
* so callers must ensure they only release locks they acquired.
|
|
1594
|
+
*
|
|
1595
|
+
* **Best practices:**
|
|
1596
|
+
* - Always call `release()` in a `finally` block to prevent lock leakage
|
|
1597
|
+
* - Only release locks you successfully acquired
|
|
1598
|
+
* - If operation fails, still release the lock to allow retry
|
|
1599
|
+
*
|
|
1600
|
+
* **Idempotency:**
|
|
1601
|
+
* Safe to call multiple times on the same resource. Releasing a non-existent
|
|
1602
|
+
* lock is a no-op.
|
|
1603
|
+
*
|
|
1604
|
+
* @param resource - The resource identifier to unlock. Must match the identifier
|
|
1605
|
+
* used in the corresponding `acquire()` call.
|
|
1606
|
+
*
|
|
1607
|
+
* @example Proper release pattern with try-finally
|
|
1608
|
+
* ```typescript
|
|
1609
|
+
* const acquired = await lock.acquire('sitemap-generation', 60000)
|
|
1610
|
+
* if (!acquired) return
|
|
1611
|
+
*
|
|
1612
|
+
* try {
|
|
1613
|
+
* await generateSitemap()
|
|
1614
|
+
* } finally {
|
|
1615
|
+
* // Always release, even if operation throws
|
|
1616
|
+
* await lock.release('sitemap-generation')
|
|
1617
|
+
* }
|
|
1618
|
+
* ```
|
|
1619
|
+
*
|
|
1620
|
+
* @example Handling operation failures
|
|
1621
|
+
* ```typescript
|
|
1622
|
+
* const acquired = await lock.acquire('data-import', 120000)
|
|
1623
|
+
* if (!acquired) return
|
|
1624
|
+
*
|
|
1625
|
+
* try {
|
|
1626
|
+
* await importData()
|
|
1627
|
+
* } catch (error) {
|
|
1628
|
+
* console.error('Import failed:', error)
|
|
1629
|
+
* // Lock still released in finally block
|
|
1630
|
+
* throw error
|
|
1631
|
+
* } finally {
|
|
1632
|
+
* await lock.release('data-import')
|
|
1633
|
+
* }
|
|
1634
|
+
* ```
|
|
1635
|
+
*/
|
|
1636
|
+
async release(resource) {
|
|
1637
|
+
this.locks.delete(resource);
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* Checks whether a resource is currently locked and has not expired.
|
|
1641
|
+
*
|
|
1642
|
+
* Performs automatic cleanup by removing expired locks during the check,
|
|
1643
|
+
* ensuring the internal map doesn't accumulate stale entries over time.
|
|
1644
|
+
*
|
|
1645
|
+
* **Use cases:**
|
|
1646
|
+
* - Pre-flight checks before attempting expensive operations
|
|
1647
|
+
* - Status monitoring and health checks
|
|
1648
|
+
* - Implementing custom retry logic
|
|
1649
|
+
* - Debugging and testing
|
|
1650
|
+
*
|
|
1651
|
+
* **Side effects:**
|
|
1652
|
+
* Automatically deletes expired locks as a garbage collection mechanism.
|
|
1653
|
+
* This is intentional to prevent memory leaks from abandoned locks.
|
|
1654
|
+
*
|
|
1655
|
+
* @param resource - The resource identifier to check for lock status.
|
|
1656
|
+
* @returns Promise resolving to `true` if resource is actively locked (not expired),
|
|
1657
|
+
* `false` if unlocked or lock has expired.
|
|
1658
|
+
*
|
|
1659
|
+
* @example Pre-flight check before starting work
|
|
1660
|
+
* ```typescript
|
|
1661
|
+
* const lock = new MemoryLock()
|
|
1662
|
+
*
|
|
1663
|
+
* if (await lock.isLocked('sitemap-generation')) {
|
|
1664
|
+
* console.log('Sitemap generation already in progress')
|
|
1665
|
+
* return
|
|
1666
|
+
* }
|
|
1667
|
+
*
|
|
1668
|
+
* // Safe to proceed
|
|
1669
|
+
* await lock.acquire('sitemap-generation', 60000)
|
|
1670
|
+
* ```
|
|
1671
|
+
*
|
|
1672
|
+
* @example Health check endpoint
|
|
1673
|
+
* ```typescript
|
|
1674
|
+
* app.get('/health/locks', async (c) => {
|
|
1675
|
+
* const isGenerating = await lock.isLocked('sitemap-generation')
|
|
1676
|
+
* const isIndexing = await lock.isLocked('search-indexing')
|
|
1677
|
+
*
|
|
1678
|
+
* return c.json({
|
|
1679
|
+
* sitemapGeneration: isGenerating ? 'in-progress' : 'idle',
|
|
1680
|
+
* searchIndexing: isIndexing ? 'in-progress' : 'idle'
|
|
1681
|
+
* })
|
|
1682
|
+
* })
|
|
1683
|
+
* ```
|
|
1684
|
+
*
|
|
1685
|
+
* @example Custom retry logic
|
|
1686
|
+
* ```typescript
|
|
1687
|
+
* let attempts = 0
|
|
1688
|
+
* while (attempts < 5) {
|
|
1689
|
+
* if (!await lock.isLocked('resource')) {
|
|
1690
|
+
* const acquired = await lock.acquire('resource', 10000)
|
|
1691
|
+
* if (acquired) break
|
|
1692
|
+
* }
|
|
1693
|
+
* await sleep(1000)
|
|
1694
|
+
* attempts++
|
|
1695
|
+
* }
|
|
1696
|
+
* ```
|
|
1697
|
+
*/
|
|
1698
|
+
async isLocked(resource) {
|
|
1699
|
+
const expiresAt = this.locks.get(resource);
|
|
1700
|
+
if (!expiresAt) {
|
|
1701
|
+
return false;
|
|
1702
|
+
}
|
|
1703
|
+
const now = Date.now();
|
|
1704
|
+
if (expiresAt <= now) {
|
|
1705
|
+
this.locks.delete(resource);
|
|
1706
|
+
return false;
|
|
1707
|
+
}
|
|
1708
|
+
return true;
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Clears all locks from memory, including both active and expired locks.
|
|
1712
|
+
*
|
|
1713
|
+
* **Use cases:**
|
|
1714
|
+
* - Test cleanup between test cases to ensure isolation
|
|
1715
|
+
* - Application shutdown to release all resources
|
|
1716
|
+
* - Manual intervention during debugging
|
|
1717
|
+
* - Resetting state after catastrophic errors
|
|
1718
|
+
*
|
|
1719
|
+
* **Warning:**
|
|
1720
|
+
* This forcibly releases ALL locks without any ownership validation.
|
|
1721
|
+
* Should not be called during normal operation in production environments.
|
|
1722
|
+
*
|
|
1723
|
+
* @example Test cleanup with beforeEach hook
|
|
1724
|
+
* ```typescript
|
|
1725
|
+
* import { describe, beforeEach, test } from 'vitest'
|
|
1726
|
+
*
|
|
1727
|
+
* const lock = new MemoryLock()
|
|
1728
|
+
*
|
|
1729
|
+
* beforeEach(async () => {
|
|
1730
|
+
* await lock.clear() // Ensure clean state for each test
|
|
1731
|
+
* })
|
|
1732
|
+
*
|
|
1733
|
+
* test('lock acquisition', async () => {
|
|
1734
|
+
* const acquired = await lock.acquire('test-resource', 5000)
|
|
1735
|
+
* expect(acquired).toBe(true)
|
|
1736
|
+
* })
|
|
1737
|
+
* ```
|
|
1738
|
+
*
|
|
1739
|
+
* @example Graceful shutdown handler
|
|
1740
|
+
* ```typescript
|
|
1741
|
+
* process.on('SIGTERM', async () => {
|
|
1742
|
+
* console.log('Shutting down, releasing all locks...')
|
|
1743
|
+
* await lock.clear()
|
|
1744
|
+
* process.exit(0)
|
|
1745
|
+
* })
|
|
1746
|
+
* ```
|
|
1747
|
+
*/
|
|
1748
|
+
async clear() {
|
|
1749
|
+
this.locks.clear();
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* Returns the number of lock entries currently stored in memory.
|
|
1753
|
+
*
|
|
1754
|
+
* **Important:** This includes BOTH active and expired locks. Expired locks
|
|
1755
|
+
* are only cleaned up during `acquire()` or `isLocked()` calls, so this count
|
|
1756
|
+
* may include stale entries.
|
|
1757
|
+
*
|
|
1758
|
+
* **Use cases:**
|
|
1759
|
+
* - Monitoring memory usage and lock accumulation
|
|
1760
|
+
* - Debugging lock leakage issues
|
|
1761
|
+
* - Testing lock lifecycle behavior
|
|
1762
|
+
* - Detecting abnormal lock retention patterns
|
|
1763
|
+
*
|
|
1764
|
+
* **Not suitable for:**
|
|
1765
|
+
* - Determining number of ACTIVE locks (use `isLocked()` on each resource)
|
|
1766
|
+
* - Production health checks (includes expired locks)
|
|
1767
|
+
*
|
|
1768
|
+
* @returns The total number of lock entries in the internal Map, including expired ones.
|
|
1769
|
+
*
|
|
1770
|
+
* @example Monitoring lock accumulation
|
|
1771
|
+
* ```typescript
|
|
1772
|
+
* const lock = new MemoryLock()
|
|
1773
|
+
*
|
|
1774
|
+
* setInterval(() => {
|
|
1775
|
+
* const count = lock.size()
|
|
1776
|
+
* if (count > 100) {
|
|
1777
|
+
* console.warn(`High lock count detected: ${count}`)
|
|
1778
|
+
* // May indicate lock leakage or missing release() calls
|
|
1779
|
+
* }
|
|
1780
|
+
* }, 60000)
|
|
1781
|
+
* ```
|
|
1782
|
+
*
|
|
1783
|
+
* @example Testing lock cleanup behavior
|
|
1784
|
+
* ```typescript
|
|
1785
|
+
* import { test, expect } from 'vitest'
|
|
1786
|
+
*
|
|
1787
|
+
* test('expired locks are cleaned up', async () => {
|
|
1788
|
+
* const lock = new MemoryLock()
|
|
1789
|
+
*
|
|
1790
|
+
* await lock.acquire('resource', 10)
|
|
1791
|
+
* expect(lock.size()).toBe(1)
|
|
1792
|
+
*
|
|
1793
|
+
* await sleep(20) // Wait for expiration
|
|
1794
|
+
* expect(lock.size()).toBe(1) // Still in map (not cleaned yet)
|
|
1795
|
+
*
|
|
1796
|
+
* await lock.isLocked('resource') // Triggers cleanup
|
|
1797
|
+
* expect(lock.size()).toBe(0) // Now removed
|
|
1798
|
+
* })
|
|
1799
|
+
* ```
|
|
1800
|
+
*/
|
|
1801
|
+
size() {
|
|
1802
|
+
return this.locks.size;
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
|
|
1806
|
+
// src/locks/RedisLock.ts
|
|
1807
|
+
import { randomUUID } from "crypto";
|
|
1808
|
+
var RedisLock = class {
|
|
1809
|
+
/**
|
|
1810
|
+
* Constructs a new RedisLock instance with the specified configuration.
|
|
1811
|
+
*
|
|
1812
|
+
* @param options - Configuration including Redis client and retry parameters.
|
|
1813
|
+
*
|
|
1814
|
+
* @example With custom retry strategy
|
|
1815
|
+
* ```typescript
|
|
1816
|
+
* const lock = new RedisLock({
|
|
1817
|
+
* client: redisClient,
|
|
1818
|
+
* keyPrefix: 'app:locks:',
|
|
1819
|
+
* retryCount: 10, // More retries for high-contention scenarios
|
|
1820
|
+
* retryDelay: 50 // Shorter delay for low-latency requirements
|
|
1821
|
+
* })
|
|
1822
|
+
* ```
|
|
1823
|
+
*/
|
|
1824
|
+
constructor(options) {
|
|
1825
|
+
this.options = options;
|
|
1826
|
+
this.keyPrefix = options.keyPrefix || "sitemap:lock:";
|
|
1827
|
+
this.retryCount = options.retryCount ?? 0;
|
|
1828
|
+
this.retryDelay = options.retryDelay ?? 100;
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Unique identifier for this lock instance.
|
|
1832
|
+
*
|
|
1833
|
+
* Generated once during construction and used for all locks acquired by this instance.
|
|
1834
|
+
* Enables ownership validation: only the instance that acquired the lock can release it.
|
|
1835
|
+
*
|
|
1836
|
+
* **Security consideration:**
|
|
1837
|
+
* UUIDs are sufficiently random to prevent lock hijacking across instances.
|
|
1838
|
+
* However, they are stored in plain text in Redis (not encrypted).
|
|
1839
|
+
*/
|
|
1840
|
+
lockId = randomUUID();
|
|
1841
|
+
/**
|
|
1842
|
+
* Redis key prefix for all locks acquired through this instance.
|
|
1843
|
+
*
|
|
1844
|
+
* Combined with resource name to form full Redis key (e.g., 'sitemap:lock:generation').
|
|
1845
|
+
* Allows namespace isolation and easier debugging in Redis CLI.
|
|
1846
|
+
*/
|
|
1847
|
+
keyPrefix;
|
|
1848
|
+
/**
|
|
1849
|
+
* Maximum number of retry attempts when lock is held by another instance.
|
|
1850
|
+
*
|
|
1851
|
+
* Set to 0 for fail-fast behavior. Higher values increase acquisition success
|
|
1852
|
+
* rate but also increase latency under contention.
|
|
1853
|
+
*/
|
|
1854
|
+
retryCount;
|
|
1855
|
+
/**
|
|
1856
|
+
* Delay in milliseconds between consecutive retry attempts.
|
|
1857
|
+
*
|
|
1858
|
+
* Should be tuned based on expected lock hold time. Typical values: 50-500ms.
|
|
1859
|
+
*/
|
|
1860
|
+
retryDelay;
|
|
1861
|
+
/**
|
|
1862
|
+
* Attempts to acquire a distributed lock using Redis SET NX EX command.
|
|
1863
|
+
*
|
|
1864
|
+
* Uses atomic Redis operations to ensure only one instance across your entire
|
|
1865
|
+
* infrastructure can hold the lock at any given time. Implements retry logic
|
|
1866
|
+
* with exponential backoff for handling transient contention.
|
|
1867
|
+
*
|
|
1868
|
+
* **Algorithm:**
|
|
1869
|
+
* 1. Convert TTL from milliseconds to seconds (Redis requirement)
|
|
1870
|
+
* 2. Attempt Redis SET key lockId EX ttl NX (atomic operation)
|
|
1871
|
+
* 3. If successful (returns 'OK'), lock acquired
|
|
1872
|
+
* 4. If failed (returns null), retry up to retryCount times with retryDelay
|
|
1873
|
+
* 5. Return true if acquired, false if all attempts exhausted
|
|
1874
|
+
*
|
|
1875
|
+
* **Atomicity guarantee:**
|
|
1876
|
+
* The combination of NX (set if Not eXists) and EX (set EXpiration) in a single
|
|
1877
|
+
* Redis command ensures no race conditions. Either the lock is acquired or it isn't.
|
|
1878
|
+
*
|
|
1879
|
+
* **Error handling:**
|
|
1880
|
+
* Redis connection errors are caught, logged to console, and treated as acquisition
|
|
1881
|
+
* failure (returns false). This fail-safe behavior prevents exceptions from bubbling
|
|
1882
|
+
* up to application code.
|
|
1883
|
+
*
|
|
1884
|
+
* **Performance:**
|
|
1885
|
+
* - Single instance: O(1) Redis operation
|
|
1886
|
+
* - With retry: O(retryCount) worst case
|
|
1887
|
+
* - Network latency: ~1-5ms per attempt (depends on Redis location)
|
|
1888
|
+
*
|
|
1889
|
+
* @param resource - Unique identifier for the resource to lock.
|
|
1890
|
+
* Combined with keyPrefix to form Redis key.
|
|
1891
|
+
* @param ttl - Time-to-live in milliseconds. Lock auto-expires after this duration.
|
|
1892
|
+
* Recommended: 2-5x expected operation time to handle slowdowns.
|
|
1893
|
+
* Minimum: 1000ms (1 second) for practical use.
|
|
1894
|
+
* @returns Promise resolving to `true` if lock acquired, `false` if held by another instance
|
|
1895
|
+
* or Redis connection failed.
|
|
1896
|
+
*
|
|
1897
|
+
* @example Basic acquisition in distributed environment
|
|
1898
|
+
* ```typescript
|
|
1899
|
+
* const lock = new RedisLock({ client: redisClient })
|
|
1900
|
+
* const acquired = await lock.acquire('sitemap-generation', 60000)
|
|
1901
|
+
*
|
|
1902
|
+
* if (!acquired) {
|
|
1903
|
+
* // Another instance (e.g., different Kubernetes pod) holds the lock
|
|
1904
|
+
* console.log('Sitemap generation in progress on another instance')
|
|
1905
|
+
* return new Response('Service busy', {
|
|
1906
|
+
* status: 503,
|
|
1907
|
+
* headers: { 'Retry-After': '30' }
|
|
1908
|
+
* })
|
|
1909
|
+
* }
|
|
1910
|
+
*
|
|
1911
|
+
* try {
|
|
1912
|
+
* await generateSitemap()
|
|
1913
|
+
* } finally {
|
|
1914
|
+
* await lock.release('sitemap-generation')
|
|
1915
|
+
* }
|
|
1916
|
+
* ```
|
|
1917
|
+
*
|
|
1918
|
+
* @example With retry logic for transient contention
|
|
1919
|
+
* ```typescript
|
|
1920
|
+
* const lock = new RedisLock({
|
|
1921
|
+
* client: redisClient,
|
|
1922
|
+
* retryCount: 5,
|
|
1923
|
+
* retryDelay: 200
|
|
1924
|
+
* })
|
|
1925
|
+
*
|
|
1926
|
+
* // Will retry 5 times with 200ms delay between attempts
|
|
1927
|
+
* const acquired = await lock.acquire('data-import', 120000)
|
|
1928
|
+
* ```
|
|
1929
|
+
*
|
|
1930
|
+
* @example Setting appropriate TTL
|
|
1931
|
+
* ```typescript
|
|
1932
|
+
* // Fast operation: short TTL
|
|
1933
|
+
* await lock.acquire('cache-rebuild', 10000) // 10 seconds
|
|
1934
|
+
*
|
|
1935
|
+
* // Slow operation: longer TTL with buffer
|
|
1936
|
+
* await lock.acquire('full-sitemap', 300000) // 5 minutes
|
|
1937
|
+
*
|
|
1938
|
+
* // Very slow operation: generous TTL
|
|
1939
|
+
* await lock.acquire('data-migration', 1800000) // 30 minutes
|
|
1940
|
+
* ```
|
|
1941
|
+
*/
|
|
1942
|
+
async acquire(resource, ttl) {
|
|
1943
|
+
const key = this.keyPrefix + resource;
|
|
1944
|
+
const ttlSeconds = Math.ceil(ttl / 1e3);
|
|
1945
|
+
let attempts = 0;
|
|
1946
|
+
while (attempts <= this.retryCount) {
|
|
1947
|
+
try {
|
|
1948
|
+
const result = await this.options.client.set(key, this.lockId, "EX", ttlSeconds, "NX");
|
|
1949
|
+
if (result === "OK") {
|
|
1950
|
+
return true;
|
|
1951
|
+
}
|
|
1952
|
+
} catch (error) {
|
|
1953
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1954
|
+
console.error(`[RedisLock] Failed to acquire lock for ${resource}:`, err.message);
|
|
1955
|
+
}
|
|
1956
|
+
attempts++;
|
|
1957
|
+
if (attempts <= this.retryCount) {
|
|
1958
|
+
await this.sleep(this.retryDelay);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
return false;
|
|
903
1962
|
}
|
|
904
1963
|
/**
|
|
905
|
-
*
|
|
1964
|
+
* Releases the distributed lock using Lua script for atomic ownership validation.
|
|
1965
|
+
*
|
|
1966
|
+
* Uses a Lua script to atomically check if the current instance owns the lock
|
|
1967
|
+
* (by comparing lockId) and delete it if so. This prevents accidentally releasing
|
|
1968
|
+
* locks held by other instances, which could cause data corruption in distributed systems.
|
|
1969
|
+
*
|
|
1970
|
+
* **Lua script logic:**
|
|
1971
|
+
* ```lua
|
|
1972
|
+
* if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
1973
|
+
* return redis.call("del", KEYS[1]) -- Delete only if owner matches
|
|
1974
|
+
* else
|
|
1975
|
+
* return 0 -- Not owner or lock expired, do nothing
|
|
1976
|
+
* end
|
|
1977
|
+
* ```
|
|
1978
|
+
*
|
|
1979
|
+
* **Why Lua scripts?**
|
|
1980
|
+
* - Atomicity: GET + comparison + DEL execute as one atomic operation
|
|
1981
|
+
* - Prevents race conditions: Lock cannot change between check and delete
|
|
1982
|
+
* - Server-side execution: No network round trips between steps
|
|
1983
|
+
*
|
|
1984
|
+
* **Error handling:**
|
|
1985
|
+
* Errors during release (e.g., Redis connection loss) are logged but do NOT throw.
|
|
1986
|
+
* This is intentional: if instance crashed or TTL expired, lock is already released.
|
|
1987
|
+
* Silent failure here prevents cascading errors in finally blocks.
|
|
1988
|
+
*
|
|
1989
|
+
* **Idempotency:**
|
|
1990
|
+
* Safe to call multiple times. Releasing an already-released or expired lock is a no-op.
|
|
1991
|
+
*
|
|
1992
|
+
* @param resource - The resource identifier to unlock. Must match the identifier
|
|
1993
|
+
* used in the corresponding `acquire()` call.
|
|
1994
|
+
*
|
|
1995
|
+
* @example Proper release pattern with try-finally
|
|
1996
|
+
* ```typescript
|
|
1997
|
+
* const acquired = await lock.acquire('sitemap-generation', 60000)
|
|
1998
|
+
* if (!acquired) return
|
|
1999
|
+
*
|
|
2000
|
+
* try {
|
|
2001
|
+
* await generateSitemap()
|
|
2002
|
+
* } finally {
|
|
2003
|
+
* // Always release, even if operation throws
|
|
2004
|
+
* await lock.release('sitemap-generation')
|
|
2005
|
+
* }
|
|
2006
|
+
* ```
|
|
2007
|
+
*
|
|
2008
|
+
* @example Handling operation failures
|
|
2009
|
+
* ```typescript
|
|
2010
|
+
* const acquired = await lock.acquire('data-processing', 120000)
|
|
2011
|
+
* if (!acquired) {
|
|
2012
|
+
* throw new Error('Could not acquire lock')
|
|
2013
|
+
* }
|
|
2014
|
+
*
|
|
2015
|
+
* try {
|
|
2016
|
+
* await processData()
|
|
2017
|
+
* } catch (error) {
|
|
2018
|
+
* console.error('Processing failed:', error)
|
|
2019
|
+
* // Lock still released in finally block
|
|
2020
|
+
* throw error
|
|
2021
|
+
* } finally {
|
|
2022
|
+
* await lock.release('data-processing')
|
|
2023
|
+
* }
|
|
2024
|
+
* ```
|
|
2025
|
+
*
|
|
2026
|
+
* @example Why ownership validation matters
|
|
2027
|
+
* ```typescript
|
|
2028
|
+
* // Instance A acquires lock with 10-second TTL
|
|
2029
|
+
* const lockA = new RedisLock({ client: redisClientA })
|
|
2030
|
+
* await lockA.acquire('task', 10000)
|
|
2031
|
+
*
|
|
2032
|
+
* // ... 11 seconds pass, lock auto-expires ...
|
|
2033
|
+
*
|
|
2034
|
+
* // Instance B acquires the now-expired lock
|
|
2035
|
+
* const lockB = new RedisLock({ client: redisClientB })
|
|
2036
|
+
* await lockB.acquire('task', 10000)
|
|
2037
|
+
*
|
|
2038
|
+
* // Instance A tries to release (after slowdown/GC pause)
|
|
2039
|
+
* await lockA.release('task')
|
|
2040
|
+
* // ✅ Lua script detects lockId mismatch, does NOT delete B's lock
|
|
2041
|
+
* // ❌ Without Lua: Would delete B's lock, causing data corruption
|
|
2042
|
+
* ```
|
|
906
2043
|
*/
|
|
907
|
-
async
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
2044
|
+
async release(resource) {
|
|
2045
|
+
const key = this.keyPrefix + resource;
|
|
2046
|
+
try {
|
|
2047
|
+
const script = `
|
|
2048
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
2049
|
+
return redis.call("del", KEYS[1])
|
|
2050
|
+
else
|
|
2051
|
+
return 0
|
|
2052
|
+
end
|
|
2053
|
+
`;
|
|
2054
|
+
await this.options.client.eval(script, 1, key, this.lockId);
|
|
2055
|
+
} catch (error) {
|
|
2056
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2057
|
+
console.error(`[RedisLock] Failed to release lock for ${resource}:`, err.message);
|
|
920
2058
|
}
|
|
921
2059
|
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Internal utility for sleeping between retry attempts.
|
|
2062
|
+
*
|
|
2063
|
+
* @param ms - Duration to sleep in milliseconds
|
|
2064
|
+
*/
|
|
2065
|
+
sleep(ms) {
|
|
2066
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2067
|
+
}
|
|
922
2068
|
};
|
|
923
2069
|
|
|
924
2070
|
// src/OrbitSitemap.ts
|
|
925
|
-
import { randomUUID } from "crypto";
|
|
2071
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
926
2072
|
|
|
927
2073
|
// src/redirect/RedirectHandler.ts
|
|
928
2074
|
var RedirectHandler = class {
|
|
@@ -931,7 +2077,10 @@ var RedirectHandler = class {
|
|
|
931
2077
|
this.options = options;
|
|
932
2078
|
}
|
|
933
2079
|
/**
|
|
934
|
-
*
|
|
2080
|
+
* Processes a list of sitemap entries and handles redirects according to the configured strategy.
|
|
2081
|
+
*
|
|
2082
|
+
* @param entries - The original list of sitemap entries.
|
|
2083
|
+
* @returns A promise resolving to the processed list of entries.
|
|
935
2084
|
*/
|
|
936
2085
|
async processEntries(entries) {
|
|
937
2086
|
const { manager, strategy, followChains, maxChainLength } = this.options;
|
|
@@ -962,7 +2111,7 @@ var RedirectHandler = class {
|
|
|
962
2111
|
}
|
|
963
2112
|
}
|
|
964
2113
|
/**
|
|
965
|
-
*
|
|
2114
|
+
* Strategy 1: Remove old URL and add the new destination URL.
|
|
966
2115
|
*/
|
|
967
2116
|
handleRemoveOldAddNew(entries, redirectMap) {
|
|
968
2117
|
const processed = [];
|
|
@@ -987,7 +2136,7 @@ var RedirectHandler = class {
|
|
|
987
2136
|
return processed;
|
|
988
2137
|
}
|
|
989
2138
|
/**
|
|
990
|
-
*
|
|
2139
|
+
* Strategy 2: Keep the original URL but mark the destination as canonical.
|
|
991
2140
|
*/
|
|
992
2141
|
handleKeepRelation(entries, redirectMap) {
|
|
993
2142
|
const processed = [];
|
|
@@ -1010,7 +2159,7 @@ var RedirectHandler = class {
|
|
|
1010
2159
|
return processed;
|
|
1011
2160
|
}
|
|
1012
2161
|
/**
|
|
1013
|
-
*
|
|
2162
|
+
* Strategy 3: Silently update the URL to the destination.
|
|
1014
2163
|
*/
|
|
1015
2164
|
handleUpdateUrl(entries, redirectMap) {
|
|
1016
2165
|
return entries.map((entry) => {
|
|
@@ -1030,7 +2179,7 @@ var RedirectHandler = class {
|
|
|
1030
2179
|
});
|
|
1031
2180
|
}
|
|
1032
2181
|
/**
|
|
1033
|
-
*
|
|
2182
|
+
* Strategy 4: Include both the original and destination URLs.
|
|
1034
2183
|
*/
|
|
1035
2184
|
handleDualMark(entries, redirectMap) {
|
|
1036
2185
|
const processed = [];
|
|
@@ -1072,15 +2221,82 @@ var MemorySitemapStorage = class {
|
|
|
1072
2221
|
this.baseUrl = baseUrl;
|
|
1073
2222
|
}
|
|
1074
2223
|
files = /* @__PURE__ */ new Map();
|
|
2224
|
+
/**
|
|
2225
|
+
* Writes sitemap content to memory.
|
|
2226
|
+
*
|
|
2227
|
+
* @param filename - The name of the file to store.
|
|
2228
|
+
* @param content - The XML or JSON content.
|
|
2229
|
+
*/
|
|
1075
2230
|
async write(filename, content) {
|
|
1076
2231
|
this.files.set(filename, content);
|
|
1077
2232
|
}
|
|
2233
|
+
/**
|
|
2234
|
+
* 使用串流方式寫入 sitemap 至記憶體,可選擇性啟用 gzip 壓縮。
|
|
2235
|
+
* 記憶體儲存會收集串流為完整字串。
|
|
2236
|
+
*
|
|
2237
|
+
* @param filename - 檔案名稱
|
|
2238
|
+
* @param stream - XML 內容的 AsyncIterable
|
|
2239
|
+
* @param options - 寫入選項(如壓縮)
|
|
2240
|
+
*
|
|
2241
|
+
* @since 3.1.0
|
|
2242
|
+
*/
|
|
2243
|
+
async writeStream(filename, stream, options) {
|
|
2244
|
+
const chunks = [];
|
|
2245
|
+
for await (const chunk of stream) {
|
|
2246
|
+
chunks.push(chunk);
|
|
2247
|
+
}
|
|
2248
|
+
const content = chunks.join("");
|
|
2249
|
+
const key = options?.compress ? toGzipFilename(filename) : filename;
|
|
2250
|
+
if (options?.compress) {
|
|
2251
|
+
const compressed = await compressToBuffer(
|
|
2252
|
+
(async function* () {
|
|
2253
|
+
yield content;
|
|
2254
|
+
})()
|
|
2255
|
+
);
|
|
2256
|
+
this.files.set(key, compressed.toString("base64"));
|
|
2257
|
+
} else {
|
|
2258
|
+
this.files.set(key, content);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* Reads sitemap content from memory.
|
|
2263
|
+
*
|
|
2264
|
+
* @param filename - The name of the file to read.
|
|
2265
|
+
* @returns A promise resolving to the file content as a string, or null if not found.
|
|
2266
|
+
*/
|
|
1078
2267
|
async read(filename) {
|
|
1079
2268
|
return this.files.get(filename) || null;
|
|
1080
2269
|
}
|
|
2270
|
+
/**
|
|
2271
|
+
* Returns a readable stream for a sitemap file in memory.
|
|
2272
|
+
*
|
|
2273
|
+
* @param filename - The name of the file to stream.
|
|
2274
|
+
* @returns A promise resolving to an async iterable of file chunks, or null if not found.
|
|
2275
|
+
*/
|
|
2276
|
+
async readStream(filename) {
|
|
2277
|
+
const content = this.files.get(filename);
|
|
2278
|
+
if (content === void 0) {
|
|
2279
|
+
return null;
|
|
2280
|
+
}
|
|
2281
|
+
return (async function* () {
|
|
2282
|
+
yield content;
|
|
2283
|
+
})();
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Checks if a sitemap file exists in memory.
|
|
2287
|
+
*
|
|
2288
|
+
* @param filename - The name of the file to check.
|
|
2289
|
+
* @returns A promise resolving to true if the file exists, false otherwise.
|
|
2290
|
+
*/
|
|
1081
2291
|
async exists(filename) {
|
|
1082
2292
|
return this.files.has(filename);
|
|
1083
2293
|
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Returns the full public URL for a sitemap file.
|
|
2296
|
+
*
|
|
2297
|
+
* @param filename - The name of the sitemap file.
|
|
2298
|
+
* @returns The public URL as a string.
|
|
2299
|
+
*/
|
|
1084
2300
|
getUrl(filename) {
|
|
1085
2301
|
const base = this.baseUrl.endsWith("/") ? this.baseUrl.slice(0, -1) : this.baseUrl;
|
|
1086
2302
|
const file = filename.startsWith("/") ? filename.slice(1) : filename;
|
|
@@ -1136,7 +2352,7 @@ var OrbitSitemap = class _OrbitSitemap {
|
|
|
1136
2352
|
});
|
|
1137
2353
|
}
|
|
1138
2354
|
/**
|
|
1139
|
-
*
|
|
2355
|
+
* Installs the sitemap module into PlanetCore.
|
|
1140
2356
|
*
|
|
1141
2357
|
* @param core - The PlanetCore instance.
|
|
1142
2358
|
*/
|
|
@@ -1147,6 +2363,9 @@ var OrbitSitemap = class _OrbitSitemap {
|
|
|
1147
2363
|
core.logger.info("[OrbitSitemap] Static mode configured. Use generate() to build sitemaps.");
|
|
1148
2364
|
}
|
|
1149
2365
|
}
|
|
2366
|
+
/**
|
|
2367
|
+
* Internal method to set up dynamic sitemap routes.
|
|
2368
|
+
*/
|
|
1150
2369
|
installDynamic(core) {
|
|
1151
2370
|
const opts = this.options;
|
|
1152
2371
|
const storage = opts.storage ?? new MemorySitemapStorage(opts.baseUrl);
|
|
@@ -1204,7 +2423,7 @@ var OrbitSitemap = class _OrbitSitemap {
|
|
|
1204
2423
|
core.router.get(shardRoute, handler);
|
|
1205
2424
|
}
|
|
1206
2425
|
/**
|
|
1207
|
-
*
|
|
2426
|
+
* Generates the sitemap (static mode only).
|
|
1208
2427
|
*
|
|
1209
2428
|
* @returns A promise that resolves when generation is complete.
|
|
1210
2429
|
* @throws {Error} If called in dynamic mode.
|
|
@@ -1216,7 +2435,7 @@ var OrbitSitemap = class _OrbitSitemap {
|
|
|
1216
2435
|
const opts = this.options;
|
|
1217
2436
|
let storage = opts.storage;
|
|
1218
2437
|
if (!storage) {
|
|
1219
|
-
const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-
|
|
2438
|
+
const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-VLN5I24C.js");
|
|
1220
2439
|
storage = new DiskSitemapStorage2(opts.outDir, opts.baseUrl);
|
|
1221
2440
|
}
|
|
1222
2441
|
let providers = opts.providers;
|
|
@@ -1246,7 +2465,7 @@ var OrbitSitemap = class _OrbitSitemap {
|
|
|
1246
2465
|
console.log(`[OrbitSitemap] Generated sitemap in ${opts.outDir}`);
|
|
1247
2466
|
}
|
|
1248
2467
|
/**
|
|
1249
|
-
*
|
|
2468
|
+
* Generates incremental sitemap updates (static mode only).
|
|
1250
2469
|
*
|
|
1251
2470
|
* @param since - Only include items modified since this date.
|
|
1252
2471
|
* @returns A promise that resolves when incremental generation is complete.
|
|
@@ -1262,7 +2481,7 @@ var OrbitSitemap = class _OrbitSitemap {
|
|
|
1262
2481
|
}
|
|
1263
2482
|
let storage = opts.storage;
|
|
1264
2483
|
if (!storage) {
|
|
1265
|
-
const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-
|
|
2484
|
+
const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-VLN5I24C.js");
|
|
1266
2485
|
storage = new DiskSitemapStorage2(opts.outDir, opts.baseUrl);
|
|
1267
2486
|
}
|
|
1268
2487
|
const incrementalGenerator = new IncrementalGenerator({
|
|
@@ -1277,7 +2496,7 @@ var OrbitSitemap = class _OrbitSitemap {
|
|
|
1277
2496
|
console.log(`[OrbitSitemap] Generated incremental sitemap in ${opts.outDir}`);
|
|
1278
2497
|
}
|
|
1279
2498
|
/**
|
|
1280
|
-
*
|
|
2499
|
+
* Generates sitemap asynchronously in the background (static mode only).
|
|
1281
2500
|
*
|
|
1282
2501
|
* @param options - Options for the async generation job.
|
|
1283
2502
|
* @returns A promise resolving to the job ID.
|
|
@@ -1288,10 +2507,10 @@ var OrbitSitemap = class _OrbitSitemap {
|
|
|
1288
2507
|
throw new Error("generateAsync() can only be called in static mode");
|
|
1289
2508
|
}
|
|
1290
2509
|
const opts = this.options;
|
|
1291
|
-
const jobId =
|
|
2510
|
+
const jobId = randomUUID2();
|
|
1292
2511
|
let storage = opts.storage;
|
|
1293
2512
|
if (!storage) {
|
|
1294
|
-
const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-
|
|
2513
|
+
const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-VLN5I24C.js");
|
|
1295
2514
|
storage = new DiskSitemapStorage2(opts.outDir, opts.baseUrl);
|
|
1296
2515
|
}
|
|
1297
2516
|
let providers = opts.providers;
|
|
@@ -1338,7 +2557,7 @@ var OrbitSitemap = class _OrbitSitemap {
|
|
|
1338
2557
|
return jobId;
|
|
1339
2558
|
}
|
|
1340
2559
|
/**
|
|
1341
|
-
*
|
|
2560
|
+
* Installs API endpoints for triggering and monitoring sitemap generation.
|
|
1342
2561
|
*
|
|
1343
2562
|
* @param core - The PlanetCore instance.
|
|
1344
2563
|
* @param basePath - The base path for the API endpoints (default: '/admin/sitemap').
|
|
@@ -1411,9 +2630,12 @@ var RouteScanner = class {
|
|
|
1411
2630
|
};
|
|
1412
2631
|
}
|
|
1413
2632
|
/**
|
|
1414
|
-
*
|
|
2633
|
+
* Scans the router and returns discovered static GET routes as sitemap entries.
|
|
1415
2634
|
*
|
|
1416
|
-
*
|
|
2635
|
+
* This method iterates through all registered routes in the Gravito router,
|
|
2636
|
+
* applying inclusion/exclusion filters and defaulting metadata for matching routes.
|
|
2637
|
+
*
|
|
2638
|
+
* @returns An array of `SitemapEntry` objects.
|
|
1417
2639
|
*/
|
|
1418
2640
|
getEntries() {
|
|
1419
2641
|
const entries = [];
|
|
@@ -1475,7 +2697,10 @@ var RedirectDetector = class {
|
|
|
1475
2697
|
this.options = options;
|
|
1476
2698
|
}
|
|
1477
2699
|
/**
|
|
1478
|
-
*
|
|
2700
|
+
* Detects redirects for a single URL using multiple strategies.
|
|
2701
|
+
*
|
|
2702
|
+
* @param url - The URL path to probe for redirects.
|
|
2703
|
+
* @returns A promise resolving to a `RedirectRule` if a redirect is found, or null.
|
|
1479
2704
|
*/
|
|
1480
2705
|
async detect(url) {
|
|
1481
2706
|
if (this.options.autoDetect?.cache) {
|
|
@@ -1507,7 +2732,10 @@ var RedirectDetector = class {
|
|
|
1507
2732
|
return null;
|
|
1508
2733
|
}
|
|
1509
2734
|
/**
|
|
1510
|
-
*
|
|
2735
|
+
* Batch detects redirects for multiple URLs with concurrency control.
|
|
2736
|
+
*
|
|
2737
|
+
* @param urls - An array of URL paths to probe.
|
|
2738
|
+
* @returns A promise resolving to a Map of URLs to their respective `RedirectRule` or null.
|
|
1511
2739
|
*/
|
|
1512
2740
|
async detectBatch(urls) {
|
|
1513
2741
|
const results = /* @__PURE__ */ new Map();
|
|
@@ -1527,7 +2755,7 @@ var RedirectDetector = class {
|
|
|
1527
2755
|
return results;
|
|
1528
2756
|
}
|
|
1529
2757
|
/**
|
|
1530
|
-
*
|
|
2758
|
+
* Detects a redirect from the configured database table.
|
|
1531
2759
|
*/
|
|
1532
2760
|
async detectFromDatabase(url) {
|
|
1533
2761
|
const { database } = this.options;
|
|
@@ -1545,14 +2773,14 @@ var RedirectDetector = class {
|
|
|
1545
2773
|
return {
|
|
1546
2774
|
from: row[columns.from],
|
|
1547
2775
|
to: row[columns.to],
|
|
1548
|
-
type: parseInt(row[columns.type], 10)
|
|
2776
|
+
type: Number.parseInt(row[columns.type], 10)
|
|
1549
2777
|
};
|
|
1550
2778
|
} catch {
|
|
1551
2779
|
return null;
|
|
1552
2780
|
}
|
|
1553
2781
|
}
|
|
1554
2782
|
/**
|
|
1555
|
-
*
|
|
2783
|
+
* Detects a redirect from a static JSON configuration file.
|
|
1556
2784
|
*/
|
|
1557
2785
|
async detectFromConfig(url) {
|
|
1558
2786
|
const { config } = this.options;
|
|
@@ -1570,7 +2798,7 @@ var RedirectDetector = class {
|
|
|
1570
2798
|
}
|
|
1571
2799
|
}
|
|
1572
2800
|
/**
|
|
1573
|
-
*
|
|
2801
|
+
* Auto-detects a redirect by sending an HTTP HEAD request.
|
|
1574
2802
|
*/
|
|
1575
2803
|
async detectAuto(url) {
|
|
1576
2804
|
const { autoDetect, baseUrl } = this.options;
|
|
@@ -1587,7 +2815,7 @@ var RedirectDetector = class {
|
|
|
1587
2815
|
method: "HEAD",
|
|
1588
2816
|
signal: controller.signal,
|
|
1589
2817
|
redirect: "manual"
|
|
1590
|
-
//
|
|
2818
|
+
// Handle redirects manually
|
|
1591
2819
|
});
|
|
1592
2820
|
clearTimeout(timeoutId);
|
|
1593
2821
|
if (response.status === 301 || response.status === 302) {
|
|
@@ -1611,7 +2839,7 @@ var RedirectDetector = class {
|
|
|
1611
2839
|
return null;
|
|
1612
2840
|
}
|
|
1613
2841
|
/**
|
|
1614
|
-
*
|
|
2842
|
+
* Caches the detection result for a URL.
|
|
1615
2843
|
*/
|
|
1616
2844
|
cacheResult(url, rule) {
|
|
1617
2845
|
if (!this.options.autoDetect?.cache) {
|
|
@@ -1632,6 +2860,11 @@ var MemoryRedirectManager = class {
|
|
|
1632
2860
|
constructor(options = {}) {
|
|
1633
2861
|
this.maxRules = options.maxRules || 1e5;
|
|
1634
2862
|
}
|
|
2863
|
+
/**
|
|
2864
|
+
* Registers a single redirect rule in memory.
|
|
2865
|
+
*
|
|
2866
|
+
* @param redirect - The redirect rule to add.
|
|
2867
|
+
*/
|
|
1635
2868
|
async register(redirect) {
|
|
1636
2869
|
this.rules.set(redirect.from, redirect);
|
|
1637
2870
|
if (this.rules.size > this.maxRules) {
|
|
@@ -1641,17 +2874,41 @@ var MemoryRedirectManager = class {
|
|
|
1641
2874
|
}
|
|
1642
2875
|
}
|
|
1643
2876
|
}
|
|
2877
|
+
/**
|
|
2878
|
+
* Registers multiple redirect rules in memory.
|
|
2879
|
+
*
|
|
2880
|
+
* @param redirects - An array of redirect rules.
|
|
2881
|
+
*/
|
|
1644
2882
|
async registerBatch(redirects) {
|
|
1645
2883
|
for (const redirect of redirects) {
|
|
1646
2884
|
await this.register(redirect);
|
|
1647
2885
|
}
|
|
1648
2886
|
}
|
|
2887
|
+
/**
|
|
2888
|
+
* Retrieves a specific redirect rule by its source path from memory.
|
|
2889
|
+
*
|
|
2890
|
+
* @param from - The source path.
|
|
2891
|
+
* @returns A promise resolving to the redirect rule, or null if not found.
|
|
2892
|
+
*/
|
|
1649
2893
|
async get(from) {
|
|
1650
2894
|
return this.rules.get(from) || null;
|
|
1651
2895
|
}
|
|
2896
|
+
/**
|
|
2897
|
+
* Retrieves all registered redirect rules from memory.
|
|
2898
|
+
*
|
|
2899
|
+
* @returns A promise resolving to an array of all redirect rules.
|
|
2900
|
+
*/
|
|
1652
2901
|
async getAll() {
|
|
1653
2902
|
return Array.from(this.rules.values());
|
|
1654
2903
|
}
|
|
2904
|
+
/**
|
|
2905
|
+
* Resolves a URL to its final destination through the redirect table.
|
|
2906
|
+
*
|
|
2907
|
+
* @param url - The URL to resolve.
|
|
2908
|
+
* @param followChains - Whether to recursively resolve chained redirects.
|
|
2909
|
+
* @param maxChainLength - Maximum depth for chain resolution.
|
|
2910
|
+
* @returns A promise resolving to the final destination URL.
|
|
2911
|
+
*/
|
|
1655
2912
|
async resolve(url, followChains = false, maxChainLength = 5) {
|
|
1656
2913
|
let current = url;
|
|
1657
2914
|
let chainLength = 0;
|
|
@@ -1684,6 +2941,11 @@ var RedisRedirectManager = class {
|
|
|
1684
2941
|
getListKey() {
|
|
1685
2942
|
return `${this.keyPrefix}list`;
|
|
1686
2943
|
}
|
|
2944
|
+
/**
|
|
2945
|
+
* Registers a single redirect rule in Redis.
|
|
2946
|
+
*
|
|
2947
|
+
* @param redirect - The redirect rule to add.
|
|
2948
|
+
*/
|
|
1687
2949
|
async register(redirect) {
|
|
1688
2950
|
const key = this.getKey(redirect.from);
|
|
1689
2951
|
const listKey = this.getListKey();
|
|
@@ -1695,11 +2957,22 @@ var RedisRedirectManager = class {
|
|
|
1695
2957
|
}
|
|
1696
2958
|
await this.client.sadd(listKey, redirect.from);
|
|
1697
2959
|
}
|
|
2960
|
+
/**
|
|
2961
|
+
* Registers multiple redirect rules in Redis.
|
|
2962
|
+
*
|
|
2963
|
+
* @param redirects - An array of redirect rules.
|
|
2964
|
+
*/
|
|
1698
2965
|
async registerBatch(redirects) {
|
|
1699
2966
|
for (const redirect of redirects) {
|
|
1700
2967
|
await this.register(redirect);
|
|
1701
2968
|
}
|
|
1702
2969
|
}
|
|
2970
|
+
/**
|
|
2971
|
+
* Retrieves a specific redirect rule by its source path from Redis.
|
|
2972
|
+
*
|
|
2973
|
+
* @param from - The source path.
|
|
2974
|
+
* @returns A promise resolving to the redirect rule, or null if not found.
|
|
2975
|
+
*/
|
|
1703
2976
|
async get(from) {
|
|
1704
2977
|
try {
|
|
1705
2978
|
const key = this.getKey(from);
|
|
@@ -1716,6 +2989,11 @@ var RedisRedirectManager = class {
|
|
|
1716
2989
|
return null;
|
|
1717
2990
|
}
|
|
1718
2991
|
}
|
|
2992
|
+
/**
|
|
2993
|
+
* Retrieves all registered redirect rules from Redis.
|
|
2994
|
+
*
|
|
2995
|
+
* @returns A promise resolving to an array of all redirect rules.
|
|
2996
|
+
*/
|
|
1719
2997
|
async getAll() {
|
|
1720
2998
|
try {
|
|
1721
2999
|
const listKey = this.getListKey();
|
|
@@ -1732,6 +3010,14 @@ var RedisRedirectManager = class {
|
|
|
1732
3010
|
return [];
|
|
1733
3011
|
}
|
|
1734
3012
|
}
|
|
3013
|
+
/**
|
|
3014
|
+
* Resolves a URL to its final destination through the Redis redirect table.
|
|
3015
|
+
*
|
|
3016
|
+
* @param url - The URL to resolve.
|
|
3017
|
+
* @param followChains - Whether to recursively resolve chained redirects.
|
|
3018
|
+
* @param maxChainLength - Maximum depth for chain resolution.
|
|
3019
|
+
* @returns A promise resolving to the final destination URL.
|
|
3020
|
+
*/
|
|
1735
3021
|
async resolve(url, followChains = false, maxChainLength = 5) {
|
|
1736
3022
|
let current = url;
|
|
1737
3023
|
let chainLength = 0;
|
|
@@ -1751,6 +3037,8 @@ var RedisRedirectManager = class {
|
|
|
1751
3037
|
};
|
|
1752
3038
|
|
|
1753
3039
|
// src/storage/GCPSitemapStorage.ts
|
|
3040
|
+
import { Readable } from "stream";
|
|
3041
|
+
import { pipeline } from "stream/promises";
|
|
1754
3042
|
var GCPSitemapStorage = class {
|
|
1755
3043
|
bucket;
|
|
1756
3044
|
prefix;
|
|
@@ -1789,6 +3077,12 @@ var GCPSitemapStorage = class {
|
|
|
1789
3077
|
const cleanPrefix = this.prefix.endsWith("/") ? this.prefix.slice(0, -1) : this.prefix;
|
|
1790
3078
|
return cleanPrefix ? `${cleanPrefix}/${filename}` : filename;
|
|
1791
3079
|
}
|
|
3080
|
+
/**
|
|
3081
|
+
* Writes sitemap content to a Google Cloud Storage object.
|
|
3082
|
+
*
|
|
3083
|
+
* @param filename - The name of the file to write.
|
|
3084
|
+
* @param content - The XML or JSON content.
|
|
3085
|
+
*/
|
|
1792
3086
|
async write(filename, content) {
|
|
1793
3087
|
const { bucket } = await this.getStorageClient();
|
|
1794
3088
|
const key = this.getKey(filename);
|
|
@@ -1800,6 +3094,41 @@ var GCPSitemapStorage = class {
|
|
|
1800
3094
|
}
|
|
1801
3095
|
});
|
|
1802
3096
|
}
|
|
3097
|
+
/**
|
|
3098
|
+
* 使用串流方式寫入 sitemap 至 GCP Cloud Storage,可選擇性啟用 gzip 壓縮。
|
|
3099
|
+
*
|
|
3100
|
+
* @param filename - 檔案名稱
|
|
3101
|
+
* @param stream - XML 內容的 AsyncIterable
|
|
3102
|
+
* @param options - 寫入選項(如壓縮、content type)
|
|
3103
|
+
*
|
|
3104
|
+
* @since 3.1.0
|
|
3105
|
+
*/
|
|
3106
|
+
async writeStream(filename, stream, options) {
|
|
3107
|
+
const { bucket } = await this.getStorageClient();
|
|
3108
|
+
const key = this.getKey(options?.compress ? toGzipFilename(filename) : filename);
|
|
3109
|
+
const file = bucket.file(key);
|
|
3110
|
+
const writeStream = file.createWriteStream({
|
|
3111
|
+
resumable: false,
|
|
3112
|
+
contentType: "application/xml",
|
|
3113
|
+
metadata: {
|
|
3114
|
+
contentEncoding: options?.compress ? "gzip" : void 0,
|
|
3115
|
+
cacheControl: "public, max-age=3600"
|
|
3116
|
+
}
|
|
3117
|
+
});
|
|
3118
|
+
const readable = Readable.from(stream, { encoding: "utf-8" });
|
|
3119
|
+
if (options?.compress) {
|
|
3120
|
+
const gzip = createCompressionStream();
|
|
3121
|
+
await pipeline(readable, gzip, writeStream);
|
|
3122
|
+
} else {
|
|
3123
|
+
await pipeline(readable, writeStream);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
/**
|
|
3127
|
+
* Reads sitemap content from a Google Cloud Storage object.
|
|
3128
|
+
*
|
|
3129
|
+
* @param filename - The name of the file to read.
|
|
3130
|
+
* @returns A promise resolving to the file content as a string, or null if not found.
|
|
3131
|
+
*/
|
|
1803
3132
|
async read(filename) {
|
|
1804
3133
|
try {
|
|
1805
3134
|
const { bucket } = await this.getStorageClient();
|
|
@@ -1818,6 +3147,42 @@ var GCPSitemapStorage = class {
|
|
|
1818
3147
|
throw error;
|
|
1819
3148
|
}
|
|
1820
3149
|
}
|
|
3150
|
+
/**
|
|
3151
|
+
* Returns a readable stream for a Google Cloud Storage object.
|
|
3152
|
+
*
|
|
3153
|
+
* @param filename - The name of the file to stream.
|
|
3154
|
+
* @returns A promise resolving to an async iterable of file chunks, or null if not found.
|
|
3155
|
+
*/
|
|
3156
|
+
async readStream(filename) {
|
|
3157
|
+
try {
|
|
3158
|
+
const { bucket } = await this.getStorageClient();
|
|
3159
|
+
const key = this.getKey(filename);
|
|
3160
|
+
const file = bucket.file(key);
|
|
3161
|
+
const [exists] = await file.exists();
|
|
3162
|
+
if (!exists) {
|
|
3163
|
+
return null;
|
|
3164
|
+
}
|
|
3165
|
+
const stream = file.createReadStream();
|
|
3166
|
+
return (async function* () {
|
|
3167
|
+
const decoder = new TextDecoder();
|
|
3168
|
+
for await (const chunk of stream) {
|
|
3169
|
+
yield decoder.decode(chunk, { stream: true });
|
|
3170
|
+
}
|
|
3171
|
+
yield decoder.decode();
|
|
3172
|
+
})();
|
|
3173
|
+
} catch (error) {
|
|
3174
|
+
if (error.code === 404) {
|
|
3175
|
+
return null;
|
|
3176
|
+
}
|
|
3177
|
+
throw error;
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
/**
|
|
3181
|
+
* Checks if a Google Cloud Storage object exists.
|
|
3182
|
+
*
|
|
3183
|
+
* @param filename - The name of the file to check.
|
|
3184
|
+
* @returns A promise resolving to true if the file exists, false otherwise.
|
|
3185
|
+
*/
|
|
1821
3186
|
async exists(filename) {
|
|
1822
3187
|
try {
|
|
1823
3188
|
const { bucket } = await this.getStorageClient();
|
|
@@ -1829,18 +3194,30 @@ var GCPSitemapStorage = class {
|
|
|
1829
3194
|
return false;
|
|
1830
3195
|
}
|
|
1831
3196
|
}
|
|
3197
|
+
/**
|
|
3198
|
+
* Returns the full public URL for a Google Cloud Storage object.
|
|
3199
|
+
*
|
|
3200
|
+
* @param filename - The name of the sitemap file.
|
|
3201
|
+
* @returns The public URL as a string.
|
|
3202
|
+
*/
|
|
1832
3203
|
getUrl(filename) {
|
|
1833
3204
|
const key = this.getKey(filename);
|
|
1834
3205
|
const base = this.baseUrl.endsWith("/") ? this.baseUrl.slice(0, -1) : this.baseUrl;
|
|
1835
3206
|
return `${base}/${key}`;
|
|
1836
3207
|
}
|
|
1837
|
-
|
|
3208
|
+
/**
|
|
3209
|
+
* Writes content to a shadow (staged) location in Google Cloud Storage.
|
|
3210
|
+
*
|
|
3211
|
+
* @param filename - The name of the file to write.
|
|
3212
|
+
* @param content - The XML or JSON content.
|
|
3213
|
+
* @param shadowId - Optional unique session identifier.
|
|
3214
|
+
*/
|
|
1838
3215
|
async writeShadow(filename, content, shadowId) {
|
|
1839
3216
|
if (!this.shadowEnabled) {
|
|
1840
3217
|
return this.write(filename, content);
|
|
1841
3218
|
}
|
|
1842
3219
|
const { bucket } = await this.getStorageClient();
|
|
1843
|
-
const id = shadowId || `shadow-${Date.now()}-${
|
|
3220
|
+
const id = shadowId || `shadow-${Date.now()}-${crypto.randomUUID()}`;
|
|
1844
3221
|
const shadowKey = this.getKey(`${filename}.shadow.${id}`);
|
|
1845
3222
|
const file = bucket.file(shadowKey);
|
|
1846
3223
|
await file.save(content, {
|
|
@@ -1850,6 +3227,11 @@ var GCPSitemapStorage = class {
|
|
|
1850
3227
|
}
|
|
1851
3228
|
});
|
|
1852
3229
|
}
|
|
3230
|
+
/**
|
|
3231
|
+
* Commits all staged shadow objects in a session to production in Google Cloud Storage.
|
|
3232
|
+
*
|
|
3233
|
+
* @param shadowId - The identifier of the session to commit.
|
|
3234
|
+
*/
|
|
1853
3235
|
async commitShadow(shadowId) {
|
|
1854
3236
|
if (!this.shadowEnabled) {
|
|
1855
3237
|
return;
|
|
@@ -1876,6 +3258,12 @@ var GCPSitemapStorage = class {
|
|
|
1876
3258
|
}
|
|
1877
3259
|
}
|
|
1878
3260
|
}
|
|
3261
|
+
/**
|
|
3262
|
+
* Lists all archived versions of a specific sitemap in Google Cloud Storage.
|
|
3263
|
+
*
|
|
3264
|
+
* @param filename - The sitemap filename.
|
|
3265
|
+
* @returns A promise resolving to an array of version identifiers.
|
|
3266
|
+
*/
|
|
1879
3267
|
async listVersions(filename) {
|
|
1880
3268
|
if (this.shadowMode !== "versioned") {
|
|
1881
3269
|
return [];
|
|
@@ -1897,6 +3285,12 @@ var GCPSitemapStorage = class {
|
|
|
1897
3285
|
return [];
|
|
1898
3286
|
}
|
|
1899
3287
|
}
|
|
3288
|
+
/**
|
|
3289
|
+
* Reverts a sitemap to a previously archived version in Google Cloud Storage.
|
|
3290
|
+
*
|
|
3291
|
+
* @param filename - The sitemap filename.
|
|
3292
|
+
* @param version - The version identifier to switch to.
|
|
3293
|
+
*/
|
|
1900
3294
|
async switchVersion(filename, version) {
|
|
1901
3295
|
if (this.shadowMode !== "versioned") {
|
|
1902
3296
|
throw new Error("Version switching is only available in versioned mode");
|
|
@@ -1916,22 +3310,51 @@ var GCPSitemapStorage = class {
|
|
|
1916
3310
|
// src/storage/MemoryProgressStorage.ts
|
|
1917
3311
|
var MemoryProgressStorage = class {
|
|
1918
3312
|
storage = /* @__PURE__ */ new Map();
|
|
3313
|
+
/**
|
|
3314
|
+
* Retrieves the progress of a specific generation job from memory.
|
|
3315
|
+
*
|
|
3316
|
+
* @param jobId - Unique identifier for the job.
|
|
3317
|
+
* @returns A promise resolving to the `SitemapProgress` object, or null if not found.
|
|
3318
|
+
*/
|
|
1919
3319
|
async get(jobId) {
|
|
1920
3320
|
const progress = this.storage.get(jobId);
|
|
1921
3321
|
return progress ? { ...progress } : null;
|
|
1922
3322
|
}
|
|
3323
|
+
/**
|
|
3324
|
+
* Initializes or overwrites a progress record in memory.
|
|
3325
|
+
*
|
|
3326
|
+
* @param jobId - Unique identifier for the job.
|
|
3327
|
+
* @param progress - The initial or current state of the job progress.
|
|
3328
|
+
*/
|
|
1923
3329
|
async set(jobId, progress) {
|
|
1924
3330
|
this.storage.set(jobId, { ...progress });
|
|
1925
3331
|
}
|
|
3332
|
+
/**
|
|
3333
|
+
* Updates specific fields of an existing progress record in memory.
|
|
3334
|
+
*
|
|
3335
|
+
* @param jobId - Unique identifier for the job.
|
|
3336
|
+
* @param updates - Object containing the fields to update.
|
|
3337
|
+
*/
|
|
1926
3338
|
async update(jobId, updates) {
|
|
1927
3339
|
const existing = this.storage.get(jobId);
|
|
1928
3340
|
if (existing) {
|
|
1929
3341
|
this.storage.set(jobId, { ...existing, ...updates });
|
|
1930
3342
|
}
|
|
1931
3343
|
}
|
|
3344
|
+
/**
|
|
3345
|
+
* Deletes a progress record from memory.
|
|
3346
|
+
*
|
|
3347
|
+
* @param jobId - Unique identifier for the job to remove.
|
|
3348
|
+
*/
|
|
1932
3349
|
async delete(jobId) {
|
|
1933
3350
|
this.storage.delete(jobId);
|
|
1934
3351
|
}
|
|
3352
|
+
/**
|
|
3353
|
+
* Lists the most recent sitemap generation jobs from memory.
|
|
3354
|
+
*
|
|
3355
|
+
* @param limit - Maximum number of records to return.
|
|
3356
|
+
* @returns A promise resolving to an array of `SitemapProgress` objects, sorted by start time.
|
|
3357
|
+
*/
|
|
1935
3358
|
async list(limit) {
|
|
1936
3359
|
const all = Array.from(this.storage.values());
|
|
1937
3360
|
const sorted = all.sort((a, b) => {
|
|
@@ -1959,6 +3382,12 @@ var RedisProgressStorage = class {
|
|
|
1959
3382
|
getListKey() {
|
|
1960
3383
|
return `${this.keyPrefix}list`;
|
|
1961
3384
|
}
|
|
3385
|
+
/**
|
|
3386
|
+
* Retrieves the progress of a specific generation job from Redis.
|
|
3387
|
+
*
|
|
3388
|
+
* @param jobId - Unique identifier for the job.
|
|
3389
|
+
* @returns A promise resolving to the `SitemapProgress` object, or null if not found.
|
|
3390
|
+
*/
|
|
1962
3391
|
async get(jobId) {
|
|
1963
3392
|
try {
|
|
1964
3393
|
const key = this.getKey(jobId);
|
|
@@ -1978,6 +3407,12 @@ var RedisProgressStorage = class {
|
|
|
1978
3407
|
return null;
|
|
1979
3408
|
}
|
|
1980
3409
|
}
|
|
3410
|
+
/**
|
|
3411
|
+
* Initializes or overwrites a progress record in Redis.
|
|
3412
|
+
*
|
|
3413
|
+
* @param jobId - Unique identifier for the job.
|
|
3414
|
+
* @param progress - The initial or current state of the job progress.
|
|
3415
|
+
*/
|
|
1981
3416
|
async set(jobId, progress) {
|
|
1982
3417
|
const key = this.getKey(jobId);
|
|
1983
3418
|
const listKey = this.getListKey();
|
|
@@ -1986,18 +3421,35 @@ var RedisProgressStorage = class {
|
|
|
1986
3421
|
await this.client.zadd(listKey, Date.now(), jobId);
|
|
1987
3422
|
await this.client.expire(listKey, this.ttl);
|
|
1988
3423
|
}
|
|
3424
|
+
/**
|
|
3425
|
+
* Updates specific fields of an existing progress record in Redis.
|
|
3426
|
+
*
|
|
3427
|
+
* @param jobId - Unique identifier for the job.
|
|
3428
|
+
* @param updates - Object containing the fields to update.
|
|
3429
|
+
*/
|
|
1989
3430
|
async update(jobId, updates) {
|
|
1990
3431
|
const existing = await this.get(jobId);
|
|
1991
3432
|
if (existing) {
|
|
1992
3433
|
await this.set(jobId, { ...existing, ...updates });
|
|
1993
3434
|
}
|
|
1994
3435
|
}
|
|
3436
|
+
/**
|
|
3437
|
+
* Deletes a progress record from Redis.
|
|
3438
|
+
*
|
|
3439
|
+
* @param jobId - Unique identifier for the job to remove.
|
|
3440
|
+
*/
|
|
1995
3441
|
async delete(jobId) {
|
|
1996
3442
|
const key = this.getKey(jobId);
|
|
1997
3443
|
const listKey = this.getListKey();
|
|
1998
3444
|
await this.client.del(key);
|
|
1999
3445
|
await this.client.zrem(listKey, jobId);
|
|
2000
3446
|
}
|
|
3447
|
+
/**
|
|
3448
|
+
* Lists the most recent sitemap generation jobs from Redis.
|
|
3449
|
+
*
|
|
3450
|
+
* @param limit - Maximum number of records to return.
|
|
3451
|
+
* @returns A promise resolving to an array of `SitemapProgress` objects, sorted by start time.
|
|
3452
|
+
*/
|
|
2001
3453
|
async list(limit) {
|
|
2002
3454
|
try {
|
|
2003
3455
|
const listKey = this.getListKey();
|
|
@@ -2074,6 +3526,12 @@ var S3SitemapStorage = class {
|
|
|
2074
3526
|
const cleanPrefix = this.prefix.endsWith("/") ? this.prefix.slice(0, -1) : this.prefix;
|
|
2075
3527
|
return cleanPrefix ? `${cleanPrefix}/${filename}` : filename;
|
|
2076
3528
|
}
|
|
3529
|
+
/**
|
|
3530
|
+
* Writes sitemap content to an S3 object.
|
|
3531
|
+
*
|
|
3532
|
+
* @param filename - The name of the file to write.
|
|
3533
|
+
* @param content - The XML or JSON content.
|
|
3534
|
+
*/
|
|
2077
3535
|
async write(filename, content) {
|
|
2078
3536
|
const s3 = await this.getS3Client();
|
|
2079
3537
|
const key = this.getKey(filename);
|
|
@@ -2086,6 +3544,48 @@ var S3SitemapStorage = class {
|
|
|
2086
3544
|
})
|
|
2087
3545
|
);
|
|
2088
3546
|
}
|
|
3547
|
+
/**
|
|
3548
|
+
* 使用串流方式寫入 sitemap 至 S3,可選擇性啟用 gzip 壓縮。
|
|
3549
|
+
* S3 需要知道 Content-Length,因此會先收集串流為 Buffer。
|
|
3550
|
+
*
|
|
3551
|
+
* @param filename - 檔案名稱
|
|
3552
|
+
* @param stream - XML 內容的 AsyncIterable
|
|
3553
|
+
* @param options - 寫入選項(如壓縮、content type)
|
|
3554
|
+
*
|
|
3555
|
+
* @since 3.1.0
|
|
3556
|
+
*/
|
|
3557
|
+
async writeStream(filename, stream, options) {
|
|
3558
|
+
const s3 = await this.getS3Client();
|
|
3559
|
+
const key = this.getKey(options?.compress ? toGzipFilename(filename) : filename);
|
|
3560
|
+
let body;
|
|
3561
|
+
const contentType = options?.contentType || "application/xml";
|
|
3562
|
+
let contentEncoding;
|
|
3563
|
+
if (options?.compress) {
|
|
3564
|
+
body = await compressToBuffer(stream);
|
|
3565
|
+
contentEncoding = "gzip";
|
|
3566
|
+
} else {
|
|
3567
|
+
const chunks = [];
|
|
3568
|
+
for await (const chunk of stream) {
|
|
3569
|
+
chunks.push(chunk);
|
|
3570
|
+
}
|
|
3571
|
+
body = chunks.join("");
|
|
3572
|
+
}
|
|
3573
|
+
await s3.client.send(
|
|
3574
|
+
new s3.PutObjectCommand({
|
|
3575
|
+
Bucket: this.bucket,
|
|
3576
|
+
Key: key,
|
|
3577
|
+
Body: body,
|
|
3578
|
+
ContentType: contentType,
|
|
3579
|
+
ContentEncoding: contentEncoding
|
|
3580
|
+
})
|
|
3581
|
+
);
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* Reads sitemap content from an S3 object.
|
|
3585
|
+
*
|
|
3586
|
+
* @param filename - The name of the file to read.
|
|
3587
|
+
* @returns A promise resolving to the file content as a string, or null if not found.
|
|
3588
|
+
*/
|
|
2089
3589
|
async read(filename) {
|
|
2090
3590
|
try {
|
|
2091
3591
|
const s3 = await this.getS3Client();
|
|
@@ -2112,6 +3612,46 @@ var S3SitemapStorage = class {
|
|
|
2112
3612
|
throw error;
|
|
2113
3613
|
}
|
|
2114
3614
|
}
|
|
3615
|
+
/**
|
|
3616
|
+
* Returns a readable stream for an S3 object.
|
|
3617
|
+
*
|
|
3618
|
+
* @param filename - The name of the file to stream.
|
|
3619
|
+
* @returns A promise resolving to an async iterable of file chunks, or null if not found.
|
|
3620
|
+
*/
|
|
3621
|
+
async readStream(filename) {
|
|
3622
|
+
try {
|
|
3623
|
+
const s3 = await this.getS3Client();
|
|
3624
|
+
const key = this.getKey(filename);
|
|
3625
|
+
const response = await s3.client.send(
|
|
3626
|
+
new s3.GetObjectCommand({
|
|
3627
|
+
Bucket: this.bucket,
|
|
3628
|
+
Key: key
|
|
3629
|
+
})
|
|
3630
|
+
);
|
|
3631
|
+
if (!response.Body) {
|
|
3632
|
+
return null;
|
|
3633
|
+
}
|
|
3634
|
+
const body = response.Body;
|
|
3635
|
+
return (async function* () {
|
|
3636
|
+
const decoder = new TextDecoder();
|
|
3637
|
+
for await (const chunk of body) {
|
|
3638
|
+
yield decoder.decode(chunk, { stream: true });
|
|
3639
|
+
}
|
|
3640
|
+
yield decoder.decode();
|
|
3641
|
+
})();
|
|
3642
|
+
} catch (error) {
|
|
3643
|
+
if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
|
|
3644
|
+
return null;
|
|
3645
|
+
}
|
|
3646
|
+
throw error;
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
/**
|
|
3650
|
+
* Checks if an S3 object exists.
|
|
3651
|
+
*
|
|
3652
|
+
* @param filename - The name of the file to check.
|
|
3653
|
+
* @returns A promise resolving to true if the file exists, false otherwise.
|
|
3654
|
+
*/
|
|
2115
3655
|
async exists(filename) {
|
|
2116
3656
|
try {
|
|
2117
3657
|
const s3 = await this.getS3Client();
|
|
@@ -2130,18 +3670,30 @@ var S3SitemapStorage = class {
|
|
|
2130
3670
|
throw error;
|
|
2131
3671
|
}
|
|
2132
3672
|
}
|
|
3673
|
+
/**
|
|
3674
|
+
* Returns the full public URL for an S3 object.
|
|
3675
|
+
*
|
|
3676
|
+
* @param filename - The name of the sitemap file.
|
|
3677
|
+
* @returns The public URL as a string.
|
|
3678
|
+
*/
|
|
2133
3679
|
getUrl(filename) {
|
|
2134
3680
|
const key = this.getKey(filename);
|
|
2135
3681
|
const base = this.baseUrl.endsWith("/") ? this.baseUrl.slice(0, -1) : this.baseUrl;
|
|
2136
3682
|
return `${base}/${key}`;
|
|
2137
3683
|
}
|
|
2138
|
-
|
|
3684
|
+
/**
|
|
3685
|
+
* Writes content to a shadow (staged) location in S3.
|
|
3686
|
+
*
|
|
3687
|
+
* @param filename - The name of the file to write.
|
|
3688
|
+
* @param content - The XML or JSON content.
|
|
3689
|
+
* @param shadowId - Optional unique session identifier.
|
|
3690
|
+
*/
|
|
2139
3691
|
async writeShadow(filename, content, shadowId) {
|
|
2140
3692
|
if (!this.shadowEnabled) {
|
|
2141
3693
|
return this.write(filename, content);
|
|
2142
3694
|
}
|
|
2143
3695
|
const s3 = await this.getS3Client();
|
|
2144
|
-
const id = shadowId || `shadow-${Date.now()}-${
|
|
3696
|
+
const id = shadowId || `shadow-${Date.now()}-${crypto.randomUUID()}`;
|
|
2145
3697
|
const shadowKey = this.getKey(`${filename}.shadow.${id}`);
|
|
2146
3698
|
await s3.client.send(
|
|
2147
3699
|
new s3.PutObjectCommand({
|
|
@@ -2152,6 +3704,11 @@ var S3SitemapStorage = class {
|
|
|
2152
3704
|
})
|
|
2153
3705
|
);
|
|
2154
3706
|
}
|
|
3707
|
+
/**
|
|
3708
|
+
* Commits all staged shadow objects in a session to production.
|
|
3709
|
+
*
|
|
3710
|
+
* @param shadowId - The identifier of the session to commit.
|
|
3711
|
+
*/
|
|
2155
3712
|
async commitShadow(shadowId) {
|
|
2156
3713
|
if (!this.shadowEnabled) {
|
|
2157
3714
|
return;
|
|
@@ -2219,6 +3776,12 @@ var S3SitemapStorage = class {
|
|
|
2219
3776
|
}
|
|
2220
3777
|
}
|
|
2221
3778
|
}
|
|
3779
|
+
/**
|
|
3780
|
+
* Lists all archived versions of a specific sitemap in S3.
|
|
3781
|
+
*
|
|
3782
|
+
* @param filename - The sitemap filename.
|
|
3783
|
+
* @returns A promise resolving to an array of version identifiers.
|
|
3784
|
+
*/
|
|
2222
3785
|
async listVersions(filename) {
|
|
2223
3786
|
if (this.shadowMode !== "versioned") {
|
|
2224
3787
|
return [];
|
|
@@ -2251,6 +3814,12 @@ var S3SitemapStorage = class {
|
|
|
2251
3814
|
return [];
|
|
2252
3815
|
}
|
|
2253
3816
|
}
|
|
3817
|
+
/**
|
|
3818
|
+
* Reverts a sitemap to a previously archived version in S3.
|
|
3819
|
+
*
|
|
3820
|
+
* @param filename - The sitemap filename.
|
|
3821
|
+
* @param version - The version identifier to switch to.
|
|
3822
|
+
*/
|
|
2254
3823
|
async switchVersion(filename, version) {
|
|
2255
3824
|
if (this.shadowMode !== "versioned") {
|
|
2256
3825
|
throw new Error("Version switching is only available in versioned mode");
|
|
@@ -2279,6 +3848,7 @@ export {
|
|
|
2279
3848
|
GenerateSitemapJob,
|
|
2280
3849
|
IncrementalGenerator,
|
|
2281
3850
|
MemoryChangeTracker,
|
|
3851
|
+
MemoryLock,
|
|
2282
3852
|
MemoryProgressStorage,
|
|
2283
3853
|
MemoryRedirectManager,
|
|
2284
3854
|
MemorySitemapStorage,
|
|
@@ -2287,6 +3857,7 @@ export {
|
|
|
2287
3857
|
RedirectDetector,
|
|
2288
3858
|
RedirectHandler,
|
|
2289
3859
|
RedisChangeTracker,
|
|
3860
|
+
RedisLock,
|
|
2290
3861
|
RedisProgressStorage,
|
|
2291
3862
|
RedisRedirectManager,
|
|
2292
3863
|
RouteScanner,
|
|
@@ -2295,6 +3866,10 @@ export {
|
|
|
2295
3866
|
SitemapGenerator,
|
|
2296
3867
|
SitemapIndex,
|
|
2297
3868
|
SitemapStream,
|
|
3869
|
+
compressToBuffer,
|
|
3870
|
+
createCompressionStream,
|
|
3871
|
+
fromGzipFilename,
|
|
2298
3872
|
generateI18nEntries,
|
|
2299
|
-
routeScanner
|
|
3873
|
+
routeScanner,
|
|
3874
|
+
toGzipFilename
|
|
2300
3875
|
};
|