@thomasfosterau/effect-sitemap 0.0.0 → 0.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/dist/index.d.ts +426 -0
- package/dist/index.js +410 -0
- package/package.json +3 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Stream from "effect/Stream";
|
|
3
|
+
import * as HttpServerResponse from "effect/http/HttpServerResponse";
|
|
4
|
+
//#region src/index.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* One sitemap entry: a site-root-relative path and an optional
|
|
7
|
+
* `YYYY-MM-DD` lastmod.
|
|
8
|
+
*
|
|
9
|
+
* @since 0.1.0
|
|
10
|
+
* @category models
|
|
11
|
+
*/
|
|
12
|
+
export interface SitemapUrl {
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly lastmod?: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The sitemap protocol's cap on one file: 50,000 URLs (and 50 MB). Split
|
|
18
|
+
* into an index past it ({@link chunkSitemap}, {@link renderSitemapIndex}).
|
|
19
|
+
*
|
|
20
|
+
* @since 0.1.0
|
|
21
|
+
* @category constants
|
|
22
|
+
*/
|
|
23
|
+
export declare const SITEMAP_MAX_URLS = 50000;
|
|
24
|
+
/**
|
|
25
|
+
* The `Cache-Control` a low-churn generated document (`sitemap.xml`,
|
|
26
|
+
* `feed.json`, `robots.txt`) is served with: the edge caches the whole
|
|
27
|
+
* document, bounding any request-time enumeration behind it to at most one
|
|
28
|
+
* per hour per edge (PERF-CACHE-002). A mutation that could change the
|
|
29
|
+
* document purges its `Cache-Tag` ({@link purgeCacheTags}) rather than
|
|
30
|
+
* relying on the TTL alone.
|
|
31
|
+
*
|
|
32
|
+
* @since 0.1.0
|
|
33
|
+
* @category constants
|
|
34
|
+
*/
|
|
35
|
+
export declare const LOW_CHURN_CACHE_CONTROL = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
|
|
36
|
+
/**
|
|
37
|
+
* What a served discovery document is cached under.
|
|
38
|
+
*
|
|
39
|
+
* @since 0.1.0
|
|
40
|
+
* @category models
|
|
41
|
+
*/
|
|
42
|
+
export interface DocumentCacheOptions {
|
|
43
|
+
/** The `Cache-Tag` a mutation purges ({@link purgeCacheTags}); absent means untagged. */
|
|
44
|
+
readonly cacheTag?: string | undefined;
|
|
45
|
+
/** Defaults to {@link LOW_CHURN_CACHE_CONTROL}. */
|
|
46
|
+
readonly cacheControl?: string | undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The five XML-reserved characters escaped for element text.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { escapeXml } from "@thomasfosterau/effect-sitemap"
|
|
54
|
+
*
|
|
55
|
+
* escapeXml(`a & b < "c"`) // "a & b < "c""
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* @since 0.1.0
|
|
59
|
+
* @category rendering
|
|
60
|
+
*/
|
|
61
|
+
export declare const escapeXml: (value: string) => string;
|
|
62
|
+
/**
|
|
63
|
+
* The `YYYY-MM-DD` day of a `Date` or an ISO timestamp string, or
|
|
64
|
+
* `undefined` when unparsable — the value `renderUrl` reads as "omit
|
|
65
|
+
* `<lastmod>`". A parse of an existing value, never a clock read.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* import { lastmodOf } from "@thomasfosterau/effect-sitemap"
|
|
70
|
+
*
|
|
71
|
+
* lastmodOf("2026-03-04T05:06:07.000Z") // "2026-03-04"
|
|
72
|
+
* lastmodOf(new Date("2026-03-04T23:59:59Z")) // "2026-03-04"
|
|
73
|
+
* lastmodOf("not a date") // undefined
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @since 0.1.0
|
|
77
|
+
* @category rendering
|
|
78
|
+
*/
|
|
79
|
+
export declare const lastmodOf: (value: unknown) => string | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* One `<url>` element on `base` (the canonical origin, never the request
|
|
82
|
+
* origin).
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* import { renderUrl } from "@thomasfosterau/effect-sitemap"
|
|
87
|
+
*
|
|
88
|
+
* renderUrl("https://example.com", { path: "/posts/1", lastmod: "2026-03-04" })
|
|
89
|
+
* // "<url><loc>https://example.com/posts/1</loc><lastmod>2026-03-04</lastmod></url>"
|
|
90
|
+
* renderUrl("https://example.com", { path: "/" }) // "<url><loc>https://example.com/</loc></url>"
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @since 0.1.0
|
|
94
|
+
* @category rendering
|
|
95
|
+
*/
|
|
96
|
+
export declare const renderUrl: (base: string, entry: SitemapUrl) => string;
|
|
97
|
+
/**
|
|
98
|
+
* The XML declaration and `<urlset>` opening tag that begin every sitemap
|
|
99
|
+
* ({@link renderSitemap}, and the first chunk of {@link sitemapStream}).
|
|
100
|
+
*
|
|
101
|
+
* @since 0.1.0
|
|
102
|
+
* @category constants
|
|
103
|
+
*/
|
|
104
|
+
export declare const URLSET_OPEN = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">";
|
|
105
|
+
/**
|
|
106
|
+
* The `<urlset>` closing tag that ends every sitemap ({@link renderSitemap},
|
|
107
|
+
* and the last chunk of {@link sitemapStream}).
|
|
108
|
+
*
|
|
109
|
+
* @since 0.1.0
|
|
110
|
+
* @category constants
|
|
111
|
+
*/
|
|
112
|
+
export declare const URLSET_CLOSE = "</urlset>";
|
|
113
|
+
/**
|
|
114
|
+
* The whole `sitemap.xml` on `base`, materialised: every `<loc>` is absolute
|
|
115
|
+
* on that canonical origin, never the request origin (CANON-001). For a
|
|
116
|
+
* bounded set (static shells plus a `LIMIT`ed enumeration);
|
|
117
|
+
* {@link sitemapStream} for a large one.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* import { renderSitemap } from "@thomasfosterau/effect-sitemap"
|
|
122
|
+
*
|
|
123
|
+
* renderSitemap("https://example.com", [{ path: "/" }, { path: "/posts/1", lastmod: "2026-03-04" }])
|
|
124
|
+
* // '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.com/</loc></url><url><loc>https://example.com/posts/1</loc><lastmod>2026-03-04</lastmod></url></urlset>'
|
|
125
|
+
* ```
|
|
126
|
+
*
|
|
127
|
+
* @since 0.1.0
|
|
128
|
+
* @category rendering
|
|
129
|
+
*/
|
|
130
|
+
export declare const renderSitemap: (base: string, entries: Iterable<SitemapUrl>) => string;
|
|
131
|
+
/**
|
|
132
|
+
* The `sitemap.xml` as a `Stream` of text chunks — the opening, one chunk
|
|
133
|
+
* per entry as the source yields it, the closing — so a large enumeration
|
|
134
|
+
* is never materialised in the isolate before the first byte goes out. Every
|
|
135
|
+
* `<loc>` is absolute on `base`, the canonical origin (CANON-001). Run
|
|
136
|
+
* the enumeration before the response is constructed when it needs a
|
|
137
|
+
* database: the body stream must not touch a per-request runtime that is
|
|
138
|
+
* tearing down.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```ts
|
|
142
|
+
* import * as Effect from "effect/Effect"
|
|
143
|
+
* import * as Stream from "effect/Stream"
|
|
144
|
+
* import { sitemapStream } from "@thomasfosterau/effect-sitemap"
|
|
145
|
+
*
|
|
146
|
+
* const chunks = await Effect.runPromise(
|
|
147
|
+
* Stream.runCollect(sitemapStream("https://example.com", Stream.fromIterable([{ path: "/a" }, { path: "/b" }]))),
|
|
148
|
+
* )
|
|
149
|
+
* chunks.length // 4 — open, two entries, close
|
|
150
|
+
* ```
|
|
151
|
+
*
|
|
152
|
+
* @since 0.1.0
|
|
153
|
+
* @category streaming
|
|
154
|
+
*/
|
|
155
|
+
export declare const sitemapStream: <E, R>(base: string, entries: Stream.Stream<SitemapUrl, E, R>) => Stream.Stream<string, E, R>;
|
|
156
|
+
/**
|
|
157
|
+
* The streamed `sitemap.xml` as an `HttpServerResponse` with the
|
|
158
|
+
* `application/xml` type, {@link LOW_CHURN_CACHE_CONTROL} and, when given,
|
|
159
|
+
* the `Cache-Tag` a mutation purges.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* import * as Effect from "effect/Effect"
|
|
164
|
+
* import * as Stream from "effect/Stream"
|
|
165
|
+
* import * as HttpServerResponse from "effect/http/HttpServerResponse"
|
|
166
|
+
* import { SITEMAP_CACHE_TAG, sitemapResponse } from "@thomasfosterau/effect-sitemap"
|
|
167
|
+
*
|
|
168
|
+
* const response = sitemapResponse("https://example.com", Stream.fromIterable([{ path: "/" }]), { cacheTag: SITEMAP_CACHE_TAG })
|
|
169
|
+
* response.headers["cache-tag"] // "sitemap"
|
|
170
|
+
* await HttpServerResponse.toWeb(response).text() // the XML document
|
|
171
|
+
* ```
|
|
172
|
+
*
|
|
173
|
+
* @since 0.1.0
|
|
174
|
+
* @category streaming
|
|
175
|
+
*/
|
|
176
|
+
export declare const sitemapResponse: <E>(base: string, entries: Stream.Stream<SitemapUrl, E>, options?: DocumentCacheOptions) => HttpServerResponse.HttpServerResponse;
|
|
177
|
+
/**
|
|
178
|
+
* Splits an enumeration into sitemap-sized files (at most `size` URLs each,
|
|
179
|
+
* default {@link SITEMAP_MAX_URLS}) for a sitemap index.
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```ts
|
|
183
|
+
* import { chunkSitemap } from "@thomasfosterau/effect-sitemap"
|
|
184
|
+
*
|
|
185
|
+
* chunkSitemap([{ path: "/a" }, { path: "/b" }, { path: "/c" }], 2).map((chunk) => chunk.length) // [2, 1]
|
|
186
|
+
* ```
|
|
187
|
+
*
|
|
188
|
+
* @since 0.1.0
|
|
189
|
+
* @category rendering
|
|
190
|
+
*/
|
|
191
|
+
export declare const chunkSitemap: (entries: ReadonlyArray<SitemapUrl>, size?: number) => ReadonlyArray<ReadonlyArray<SitemapUrl>>;
|
|
192
|
+
/**
|
|
193
|
+
* A `sitemap.xml` index over several sitemap files, each a site-root-relative
|
|
194
|
+
* path (`/sitemaps/posts-1.xml`) with an optional lastmod.
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* import { renderSitemapIndex } from "@thomasfosterau/effect-sitemap"
|
|
199
|
+
*
|
|
200
|
+
* renderSitemapIndex("https://example.com", [{ path: "/sitemap-1.xml", lastmod: "2026-03-04" }, { path: "/sitemap-2.xml" }])
|
|
201
|
+
* // '<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://example.com/sitemap-1.xml</loc><lastmod>2026-03-04</lastmod></sitemap><sitemap><loc>https://example.com/sitemap-2.xml</loc></sitemap></sitemapindex>'
|
|
202
|
+
* ```
|
|
203
|
+
*
|
|
204
|
+
* @since 0.1.0
|
|
205
|
+
* @category rendering
|
|
206
|
+
*/
|
|
207
|
+
export declare const renderSitemapIndex: (base: string, sitemaps: Iterable<SitemapUrl>) => string;
|
|
208
|
+
/**
|
|
209
|
+
* A JSON Feed 1.1 item (the subset emitted).
|
|
210
|
+
*
|
|
211
|
+
* @since 0.1.0
|
|
212
|
+
* @category models
|
|
213
|
+
*/
|
|
214
|
+
export interface JsonFeedItem {
|
|
215
|
+
readonly id: string;
|
|
216
|
+
readonly url: string;
|
|
217
|
+
readonly title?: string;
|
|
218
|
+
readonly summary?: string;
|
|
219
|
+
readonly date_published: string;
|
|
220
|
+
readonly date_modified: string;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* A JSON Feed 1.1 document (the subset emitted).
|
|
224
|
+
*
|
|
225
|
+
* @since 0.1.0
|
|
226
|
+
* @category models
|
|
227
|
+
*/
|
|
228
|
+
export interface JsonFeed {
|
|
229
|
+
readonly version: "https://jsonfeed.org/version/1.1";
|
|
230
|
+
readonly title: string;
|
|
231
|
+
readonly home_page_url: string;
|
|
232
|
+
readonly feed_url: string;
|
|
233
|
+
readonly description: string;
|
|
234
|
+
readonly items: ReadonlyArray<JsonFeedItem>;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* One feed entry: a site-root-relative path, its timestamps and its text.
|
|
238
|
+
* `title` / `summary` are `null` when the row has none (the SQL wire shape
|
|
239
|
+
* an enumeration produces) and omitted from the item.
|
|
240
|
+
*
|
|
241
|
+
* @since 0.1.0
|
|
242
|
+
* @category models
|
|
243
|
+
*/
|
|
244
|
+
export interface FeedEntry {
|
|
245
|
+
readonly path: string;
|
|
246
|
+
/** ISO 8601 datetime of the most recent version — the item's `date_modified`. */
|
|
247
|
+
readonly updatedAt: string;
|
|
248
|
+
/** ISO 8601 datetime of the earliest version — the item's `date_published`. */
|
|
249
|
+
readonly publishedAt: string;
|
|
250
|
+
readonly title?: string | null | undefined;
|
|
251
|
+
readonly summary?: string | null | undefined;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* What names a feed.
|
|
255
|
+
*
|
|
256
|
+
* @since 0.1.0
|
|
257
|
+
* @category models
|
|
258
|
+
*/
|
|
259
|
+
export interface JsonFeedOptions {
|
|
260
|
+
/** The canonical origin, never the request origin. */
|
|
261
|
+
readonly base: string;
|
|
262
|
+
readonly title: string;
|
|
263
|
+
readonly description: string;
|
|
264
|
+
/** The feed's own path. Defaults to `/feed.json`. */
|
|
265
|
+
readonly feedPath?: string | undefined;
|
|
266
|
+
/** The home page's path. Defaults to `/`. */
|
|
267
|
+
readonly homePath?: string | undefined;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The item bound for a change-discovery feed: "latest changes", not a
|
|
271
|
+
* complete listing (DISC-010).
|
|
272
|
+
*
|
|
273
|
+
* @since 0.1.0
|
|
274
|
+
* @category constants
|
|
275
|
+
*/
|
|
276
|
+
export declare const FEED_ITEM_LIMIT = 50;
|
|
277
|
+
/**
|
|
278
|
+
* Serialises feed entries into a JSON Feed 1.1 document. Pure. Item `id`
|
|
279
|
+
* and `url` are the canonical absolute URL on `options.base`, never the
|
|
280
|
+
* request origin (CANON-001); `title` / `summary` are omitted, never
|
|
281
|
+
* `null`-valued keys, when absent; entry order is preserved (the caller is
|
|
282
|
+
* responsible for newest-first).
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* ```ts
|
|
286
|
+
* import { buildJsonFeed } from "@thomasfosterau/effect-sitemap"
|
|
287
|
+
*
|
|
288
|
+
* const feed = buildJsonFeed(
|
|
289
|
+
* { base: "https://example.com", title: "Example — Latest changes", description: "The 50 most recent changes." },
|
|
290
|
+
* [{ path: "/posts/1", publishedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-02-01T00:00:00.000Z", title: "Hello", summary: null }],
|
|
291
|
+
* )
|
|
292
|
+
* feed.items[0]?.url // "https://example.com/posts/1"
|
|
293
|
+
* "summary" in (feed.items[0] ?? {}) // false
|
|
294
|
+
* ```
|
|
295
|
+
*
|
|
296
|
+
* @since 0.1.0
|
|
297
|
+
* @category feed
|
|
298
|
+
*/
|
|
299
|
+
export declare const buildJsonFeed: (options: JsonFeedOptions, entries: ReadonlyArray<FeedEntry>) => JsonFeed;
|
|
300
|
+
/**
|
|
301
|
+
* The `Content-Type` a JSON Feed is served with.
|
|
302
|
+
*
|
|
303
|
+
* @since 0.1.0
|
|
304
|
+
* @category constants
|
|
305
|
+
*/
|
|
306
|
+
export declare const JSON_FEED_CONTENT_TYPE = "application/feed+json";
|
|
307
|
+
/**
|
|
308
|
+
* A JSON Feed as an `HttpServerResponse`: 2-space pretty-printed (a
|
|
309
|
+
* hand-readable discovery document; changing the indent changes the bytes on
|
|
310
|
+
* the wire and in every cached copy), `application/feed+json`,
|
|
311
|
+
* {@link LOW_CHURN_CACHE_CONTROL} and, when given, the `Cache-Tag`.
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* ```ts
|
|
315
|
+
* import * as HttpServerResponse from "effect/http/HttpServerResponse"
|
|
316
|
+
* import { buildJsonFeed, FEED_CACHE_TAG, jsonFeedResponse } from "@thomasfosterau/effect-sitemap"
|
|
317
|
+
*
|
|
318
|
+
* const feed = buildJsonFeed({ base: "https://example.com", title: "t", description: "d" }, [])
|
|
319
|
+
* const response = jsonFeedResponse(feed, { cacheTag: FEED_CACHE_TAG })
|
|
320
|
+
* response.headers["content-type"] // "application/feed+json"
|
|
321
|
+
* const text = await HttpServerResponse.toWeb(response).text()
|
|
322
|
+
* text.startsWith("{\n \"version\"") // true
|
|
323
|
+
* ```
|
|
324
|
+
*
|
|
325
|
+
* @since 0.1.0
|
|
326
|
+
* @category feed
|
|
327
|
+
*/
|
|
328
|
+
export declare const jsonFeedResponse: (feed: JsonFeed, options?: DocumentCacheOptions) => HttpServerResponse.HttpServerResponse;
|
|
329
|
+
/**
|
|
330
|
+
* The `Cache-Tag` on every `feed.json` response.
|
|
331
|
+
*
|
|
332
|
+
* @since 0.1.0
|
|
333
|
+
* @category constants
|
|
334
|
+
*/
|
|
335
|
+
export declare const FEED_CACHE_TAG = "feed";
|
|
336
|
+
/**
|
|
337
|
+
* The `Cache-Tag` on every `sitemap.xml` response.
|
|
338
|
+
*
|
|
339
|
+
* @since 0.1.0
|
|
340
|
+
* @category constants
|
|
341
|
+
*/
|
|
342
|
+
export declare const SITEMAP_CACHE_TAG = "sitemap";
|
|
343
|
+
/**
|
|
344
|
+
* Builds a `purgeTagsForResourceType`: the tags a mutation to one resource
|
|
345
|
+
* of `type` may have staled — both aggregate documents when the type can
|
|
346
|
+
* appear in them, nothing otherwise. Deliberately decided from the type
|
|
347
|
+
* alone, with no per-request visibility check: a write to an always-private
|
|
348
|
+
* resource of an eligible type still purges — a wasted purge, not a missed
|
|
349
|
+
* one. `eligibleTypes` are the resource-type segments the documents
|
|
350
|
+
* enumerate against (the values of the application's route-segment map).
|
|
351
|
+
*
|
|
352
|
+
* @example
|
|
353
|
+
* ```ts
|
|
354
|
+
* import { FEED_CACHE_TAG, makePurgeTagsForResourceType, SITEMAP_CACHE_TAG } from "@thomasfosterau/effect-sitemap"
|
|
355
|
+
*
|
|
356
|
+
* const purgeTagsForResourceType = makePurgeTagsForResourceType(["posts", "people"])
|
|
357
|
+
* purgeTagsForResourceType("posts") // [SITEMAP_CACHE_TAG, FEED_CACHE_TAG]
|
|
358
|
+
* purgeTagsForResourceType("comments") // []
|
|
359
|
+
* ```
|
|
360
|
+
*
|
|
361
|
+
* @since 0.1.0
|
|
362
|
+
* @category purging
|
|
363
|
+
*/
|
|
364
|
+
export declare const makePurgeTagsForResourceType: (eligibleTypes: Iterable<string>, tags?: ReadonlyArray<string>) => ((type: string) => ReadonlyArray<string>);
|
|
365
|
+
/**
|
|
366
|
+
* The subset of a Cloudflare `ExecutionContext` the purge needs — narrowed
|
|
367
|
+
* so a test can pass a bare `{ cache: { purge } }` rather than a platform
|
|
368
|
+
* `ExecutionContext`. `cache` is absent in local dev, where Workers Cache is
|
|
369
|
+
* not emulated.
|
|
370
|
+
*
|
|
371
|
+
* @since 0.1.0
|
|
372
|
+
* @category models
|
|
373
|
+
*/
|
|
374
|
+
export interface CachePurgeContext {
|
|
375
|
+
readonly cache?: {
|
|
376
|
+
purge(options: {
|
|
377
|
+
readonly tags: Array<string>;
|
|
378
|
+
}): Promise<{
|
|
379
|
+
readonly success: boolean;
|
|
380
|
+
readonly errors: ReadonlyArray<unknown>;
|
|
381
|
+
}>;
|
|
382
|
+
} | undefined;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Purges the given `Cache-Tag`s from THIS Worker's Workers Cache (a
|
|
386
|
+
* `purge({ tags })` purges only the calling Worker's own cache). A no-op
|
|
387
|
+
* for an empty tag list or an absent `ctx.cache`; a logged, never failing,
|
|
388
|
+
* outcome otherwise — the purge call rejecting, or *resolving* with
|
|
389
|
+
* `success: false` (a Cloudflare-side failure resolves rather than rejects).
|
|
390
|
+
* This is the purge half of PERF-CACHE-002: a mutation purges by
|
|
391
|
+
* `Cache-Tag` rather than waiting out the TTL. A dropped purge must never
|
|
392
|
+
* surface on the mutation that triggered it: the surface serves stale
|
|
393
|
+
* content until its own `s-maxage` elapses, the accepted failure mode.
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* ```ts
|
|
397
|
+
* import * as Effect from "effect/Effect"
|
|
398
|
+
* import { purgeCacheTags } from "@thomasfosterau/effect-sitemap"
|
|
399
|
+
*
|
|
400
|
+
* const purged: Array<ReadonlyArray<string>> = []
|
|
401
|
+
* const ctx = { cache: { purge: async ({ tags }: { tags: Array<string> }) => { purged.push(tags); return { success: true, errors: [] } } } }
|
|
402
|
+
* await Effect.runPromise(purgeCacheTags(ctx, ["sitemap", "feed"]))
|
|
403
|
+
* await Effect.runPromise(purgeCacheTags(undefined, ["sitemap"])) // no cache: no-op
|
|
404
|
+
* purged // [["sitemap", "feed"]]
|
|
405
|
+
* ```
|
|
406
|
+
*
|
|
407
|
+
* @since 0.1.0
|
|
408
|
+
* @category purging
|
|
409
|
+
*/
|
|
410
|
+
export declare const purgeCacheTags: (ctx: CachePurgeContext | undefined, tags: ReadonlyArray<string>) => Effect.Effect<void>;
|
|
411
|
+
/**
|
|
412
|
+
* {@link purgeCacheTags} as a `Promise`, for the plain-JS seam a front
|
|
413
|
+
* door hands to `ctx.waitUntil` after the response has gone out.
|
|
414
|
+
*
|
|
415
|
+
* @example
|
|
416
|
+
* ```ts
|
|
417
|
+
* import { purgeCacheTagsPromise } from "@thomasfosterau/effect-sitemap"
|
|
418
|
+
*
|
|
419
|
+
* await purgeCacheTagsPromise(undefined, ["sitemap"]) // resolves; nothing to purge against
|
|
420
|
+
* ```
|
|
421
|
+
*
|
|
422
|
+
* @since 0.1.0
|
|
423
|
+
* @category purging
|
|
424
|
+
*/
|
|
425
|
+
export declare const purgeCacheTagsPromise: (ctx: CachePurgeContext | undefined, tags: ReadonlyArray<string>) => Promise<void>;
|
|
426
|
+
//#endregion
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import * as Arr from "effect/Array";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as HashSet from "effect/HashSet";
|
|
4
|
+
import * as Predicate from "effect/Predicate";
|
|
5
|
+
import * as Stream from "effect/Stream";
|
|
6
|
+
import * as HttpServerResponse from "effect/http/HttpServerResponse";
|
|
7
|
+
import { describeError, logErrorEvent } from "@thomasfosterau/effect-web/observability";
|
|
8
|
+
//#region src/index.ts
|
|
9
|
+
/**
|
|
10
|
+
* `@thomasfosterau/effect-sitemap` — the discovery documents an
|
|
11
|
+
* application serves and the cache they live behind: `sitemap.xml`
|
|
12
|
+
* (rendered whole, or streamed), a sitemap index, `feed.json` (JSON Feed
|
|
13
|
+
* 1.1), and the `Cache-Tag` purge that keeps the edge copies fresh.
|
|
14
|
+
*
|
|
15
|
+
* Everything here takes a site-root-relative `path`; mapping a resource to
|
|
16
|
+
* its route stays in the application.
|
|
17
|
+
*
|
|
18
|
+
* No XML escaping is applied to `<loc>`: every location is the canonical
|
|
19
|
+
* origin plus a path an application derives from validated ids or a
|
|
20
|
+
* hand-maintained list. If authored text (a title) is ever emitted it MUST
|
|
21
|
+
* be escaped first; {@link escapeXml} is here for that.
|
|
22
|
+
*
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The sitemap protocol's cap on one file: 50,000 URLs (and 50 MB). Split
|
|
27
|
+
* into an index past it ({@link chunkSitemap}, {@link renderSitemapIndex}).
|
|
28
|
+
*
|
|
29
|
+
* @since 0.1.0
|
|
30
|
+
* @category constants
|
|
31
|
+
*/
|
|
32
|
+
const SITEMAP_MAX_URLS = 5e4;
|
|
33
|
+
/**
|
|
34
|
+
* The `Cache-Control` a low-churn generated document (`sitemap.xml`,
|
|
35
|
+
* `feed.json`, `robots.txt`) is served with: the edge caches the whole
|
|
36
|
+
* document, bounding any request-time enumeration behind it to at most one
|
|
37
|
+
* per hour per edge (PERF-CACHE-002). A mutation that could change the
|
|
38
|
+
* document purges its `Cache-Tag` ({@link purgeCacheTags}) rather than
|
|
39
|
+
* relying on the TTL alone.
|
|
40
|
+
*
|
|
41
|
+
* @since 0.1.0
|
|
42
|
+
* @category constants
|
|
43
|
+
*/
|
|
44
|
+
const LOW_CHURN_CACHE_CONTROL = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
|
|
45
|
+
/** The cache headers of a served document; the tag only when given. */
|
|
46
|
+
const documentHeaders = (options) => {
|
|
47
|
+
const control = options.cacheControl ?? "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
|
|
48
|
+
return options.cacheTag === void 0 ? { "cache-control": control } : {
|
|
49
|
+
"cache-control": control,
|
|
50
|
+
"cache-tag": options.cacheTag
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The five XML-reserved characters escaped for element text.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* import { escapeXml } from "@thomasfosterau/effect-sitemap"
|
|
59
|
+
*
|
|
60
|
+
* escapeXml(`a & b < "c"`) // "a & b < "c""
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @since 0.1.0
|
|
64
|
+
* @category rendering
|
|
65
|
+
*/
|
|
66
|
+
const escapeXml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
67
|
+
/**
|
|
68
|
+
* The `YYYY-MM-DD` day of a `Date` or an ISO timestamp string, or
|
|
69
|
+
* `undefined` when unparsable — the value `renderUrl` reads as "omit
|
|
70
|
+
* `<lastmod>`". A parse of an existing value, never a clock read.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* import { lastmodOf } from "@thomasfosterau/effect-sitemap"
|
|
75
|
+
*
|
|
76
|
+
* lastmodOf("2026-03-04T05:06:07.000Z") // "2026-03-04"
|
|
77
|
+
* lastmodOf(new Date("2026-03-04T23:59:59Z")) // "2026-03-04"
|
|
78
|
+
* lastmodOf("not a date") // undefined
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* @since 0.1.0
|
|
82
|
+
* @category rendering
|
|
83
|
+
*/
|
|
84
|
+
const lastmodOf = (value) => {
|
|
85
|
+
if (Predicate.isDate(value)) return Number.isNaN(value.getTime()) ? void 0 : value.toISOString().slice(0, 10);
|
|
86
|
+
if (Predicate.isString(value) && /^\d{4}-\d{2}-\d{2}/.test(value)) return value.slice(0, 10);
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* One `<url>` element on `base` (the canonical origin, never the request
|
|
90
|
+
* origin).
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```ts
|
|
94
|
+
* import { renderUrl } from "@thomasfosterau/effect-sitemap"
|
|
95
|
+
*
|
|
96
|
+
* renderUrl("https://example.com", { path: "/posts/1", lastmod: "2026-03-04" })
|
|
97
|
+
* // "<url><loc>https://example.com/posts/1</loc><lastmod>2026-03-04</lastmod></url>"
|
|
98
|
+
* renderUrl("https://example.com", { path: "/" }) // "<url><loc>https://example.com/</loc></url>"
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* @since 0.1.0
|
|
102
|
+
* @category rendering
|
|
103
|
+
*/
|
|
104
|
+
const renderUrl = (base, entry) => {
|
|
105
|
+
return `<url><loc>${new URL(entry.path, base).toString()}</loc>${entry.lastmod === void 0 ? "" : `<lastmod>${entry.lastmod}</lastmod>`}</url>`;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* The XML declaration and `<urlset>` opening tag that begin every sitemap
|
|
109
|
+
* ({@link renderSitemap}, and the first chunk of {@link sitemapStream}).
|
|
110
|
+
*
|
|
111
|
+
* @since 0.1.0
|
|
112
|
+
* @category constants
|
|
113
|
+
*/
|
|
114
|
+
const URLSET_OPEN = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`;
|
|
115
|
+
/**
|
|
116
|
+
* The `<urlset>` closing tag that ends every sitemap ({@link renderSitemap},
|
|
117
|
+
* and the last chunk of {@link sitemapStream}).
|
|
118
|
+
*
|
|
119
|
+
* @since 0.1.0
|
|
120
|
+
* @category constants
|
|
121
|
+
*/
|
|
122
|
+
const URLSET_CLOSE = `</urlset>`;
|
|
123
|
+
/**
|
|
124
|
+
* The whole `sitemap.xml` on `base`, materialised: every `<loc>` is absolute
|
|
125
|
+
* on that canonical origin, never the request origin (CANON-001). For a
|
|
126
|
+
* bounded set (static shells plus a `LIMIT`ed enumeration);
|
|
127
|
+
* {@link sitemapStream} for a large one.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```ts
|
|
131
|
+
* import { renderSitemap } from "@thomasfosterau/effect-sitemap"
|
|
132
|
+
*
|
|
133
|
+
* renderSitemap("https://example.com", [{ path: "/" }, { path: "/posts/1", lastmod: "2026-03-04" }])
|
|
134
|
+
* // '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.com/</loc></url><url><loc>https://example.com/posts/1</loc><lastmod>2026-03-04</lastmod></url></urlset>'
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* @since 0.1.0
|
|
138
|
+
* @category rendering
|
|
139
|
+
*/
|
|
140
|
+
const renderSitemap = (base, entries) => [
|
|
141
|
+
URLSET_OPEN,
|
|
142
|
+
...Arr.map(Arr.fromIterable(entries), (entry) => renderUrl(base, entry)),
|
|
143
|
+
URLSET_CLOSE
|
|
144
|
+
].join("");
|
|
145
|
+
/**
|
|
146
|
+
* The `sitemap.xml` as a `Stream` of text chunks — the opening, one chunk
|
|
147
|
+
* per entry as the source yields it, the closing — so a large enumeration
|
|
148
|
+
* is never materialised in the isolate before the first byte goes out. Every
|
|
149
|
+
* `<loc>` is absolute on `base`, the canonical origin (CANON-001). Run
|
|
150
|
+
* the enumeration before the response is constructed when it needs a
|
|
151
|
+
* database: the body stream must not touch a per-request runtime that is
|
|
152
|
+
* tearing down.
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```ts
|
|
156
|
+
* import * as Effect from "effect/Effect"
|
|
157
|
+
* import * as Stream from "effect/Stream"
|
|
158
|
+
* import { sitemapStream } from "@thomasfosterau/effect-sitemap"
|
|
159
|
+
*
|
|
160
|
+
* const chunks = await Effect.runPromise(
|
|
161
|
+
* Stream.runCollect(sitemapStream("https://example.com", Stream.fromIterable([{ path: "/a" }, { path: "/b" }]))),
|
|
162
|
+
* )
|
|
163
|
+
* chunks.length // 4 — open, two entries, close
|
|
164
|
+
* ```
|
|
165
|
+
*
|
|
166
|
+
* @since 0.1.0
|
|
167
|
+
* @category streaming
|
|
168
|
+
*/
|
|
169
|
+
const sitemapStream = (base, entries) => Stream.concat(Stream.concat(Stream.succeed(URLSET_OPEN), Stream.map(entries, (entry) => renderUrl(base, entry))), Stream.succeed(URLSET_CLOSE));
|
|
170
|
+
/**
|
|
171
|
+
* The streamed `sitemap.xml` as an `HttpServerResponse` with the
|
|
172
|
+
* `application/xml` type, {@link LOW_CHURN_CACHE_CONTROL} and, when given,
|
|
173
|
+
* the `Cache-Tag` a mutation purges.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```ts
|
|
177
|
+
* import * as Effect from "effect/Effect"
|
|
178
|
+
* import * as Stream from "effect/Stream"
|
|
179
|
+
* import * as HttpServerResponse from "effect/http/HttpServerResponse"
|
|
180
|
+
* import { SITEMAP_CACHE_TAG, sitemapResponse } from "@thomasfosterau/effect-sitemap"
|
|
181
|
+
*
|
|
182
|
+
* const response = sitemapResponse("https://example.com", Stream.fromIterable([{ path: "/" }]), { cacheTag: SITEMAP_CACHE_TAG })
|
|
183
|
+
* response.headers["cache-tag"] // "sitemap"
|
|
184
|
+
* await HttpServerResponse.toWeb(response).text() // the XML document
|
|
185
|
+
* ```
|
|
186
|
+
*
|
|
187
|
+
* @since 0.1.0
|
|
188
|
+
* @category streaming
|
|
189
|
+
*/
|
|
190
|
+
const sitemapResponse = (base, entries, options = {}) => HttpServerResponse.stream(Stream.encodeText(sitemapStream(base, entries)), {
|
|
191
|
+
contentType: "application/xml",
|
|
192
|
+
headers: documentHeaders(options)
|
|
193
|
+
});
|
|
194
|
+
/**
|
|
195
|
+
* Splits an enumeration into sitemap-sized files (at most `size` URLs each,
|
|
196
|
+
* default {@link SITEMAP_MAX_URLS}) for a sitemap index.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```ts
|
|
200
|
+
* import { chunkSitemap } from "@thomasfosterau/effect-sitemap"
|
|
201
|
+
*
|
|
202
|
+
* chunkSitemap([{ path: "/a" }, { path: "/b" }, { path: "/c" }], 2).map((chunk) => chunk.length) // [2, 1]
|
|
203
|
+
* ```
|
|
204
|
+
*
|
|
205
|
+
* @since 0.1.0
|
|
206
|
+
* @category rendering
|
|
207
|
+
*/
|
|
208
|
+
const chunkSitemap = (entries, size = SITEMAP_MAX_URLS) => Arr.isReadonlyArrayEmpty(entries) ? [] : Arr.chunksOf(entries, size);
|
|
209
|
+
/**
|
|
210
|
+
* A `sitemap.xml` index over several sitemap files, each a site-root-relative
|
|
211
|
+
* path (`/sitemaps/posts-1.xml`) with an optional lastmod.
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```ts
|
|
215
|
+
* import { renderSitemapIndex } from "@thomasfosterau/effect-sitemap"
|
|
216
|
+
*
|
|
217
|
+
* renderSitemapIndex("https://example.com", [{ path: "/sitemap-1.xml", lastmod: "2026-03-04" }, { path: "/sitemap-2.xml" }])
|
|
218
|
+
* // '<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://example.com/sitemap-1.xml</loc><lastmod>2026-03-04</lastmod></sitemap><sitemap><loc>https://example.com/sitemap-2.xml</loc></sitemap></sitemapindex>'
|
|
219
|
+
* ```
|
|
220
|
+
*
|
|
221
|
+
* @since 0.1.0
|
|
222
|
+
* @category rendering
|
|
223
|
+
*/
|
|
224
|
+
const renderSitemapIndex = (base, sitemaps) => [
|
|
225
|
+
`<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
|
|
226
|
+
...Arr.map(Arr.fromIterable(sitemaps), (sitemap) => {
|
|
227
|
+
return `<sitemap><loc>${new URL(sitemap.path, base).toString()}</loc>${sitemap.lastmod === void 0 ? "" : `<lastmod>${sitemap.lastmod}</lastmod>`}</sitemap>`;
|
|
228
|
+
}),
|
|
229
|
+
`</sitemapindex>`
|
|
230
|
+
].join("");
|
|
231
|
+
/**
|
|
232
|
+
* The item bound for a change-discovery feed: "latest changes", not a
|
|
233
|
+
* complete listing (DISC-010).
|
|
234
|
+
*
|
|
235
|
+
* @since 0.1.0
|
|
236
|
+
* @category constants
|
|
237
|
+
*/
|
|
238
|
+
const FEED_ITEM_LIMIT = 50;
|
|
239
|
+
/**
|
|
240
|
+
* Serialises feed entries into a JSON Feed 1.1 document. Pure. Item `id`
|
|
241
|
+
* and `url` are the canonical absolute URL on `options.base`, never the
|
|
242
|
+
* request origin (CANON-001); `title` / `summary` are omitted, never
|
|
243
|
+
* `null`-valued keys, when absent; entry order is preserved (the caller is
|
|
244
|
+
* responsible for newest-first).
|
|
245
|
+
*
|
|
246
|
+
* @example
|
|
247
|
+
* ```ts
|
|
248
|
+
* import { buildJsonFeed } from "@thomasfosterau/effect-sitemap"
|
|
249
|
+
*
|
|
250
|
+
* const feed = buildJsonFeed(
|
|
251
|
+
* { base: "https://example.com", title: "Example — Latest changes", description: "The 50 most recent changes." },
|
|
252
|
+
* [{ path: "/posts/1", publishedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-02-01T00:00:00.000Z", title: "Hello", summary: null }],
|
|
253
|
+
* )
|
|
254
|
+
* feed.items[0]?.url // "https://example.com/posts/1"
|
|
255
|
+
* "summary" in (feed.items[0] ?? {}) // false
|
|
256
|
+
* ```
|
|
257
|
+
*
|
|
258
|
+
* @since 0.1.0
|
|
259
|
+
* @category feed
|
|
260
|
+
*/
|
|
261
|
+
const buildJsonFeed = (options, entries) => ({
|
|
262
|
+
version: "https://jsonfeed.org/version/1.1",
|
|
263
|
+
title: options.title,
|
|
264
|
+
home_page_url: new URL(options.homePath ?? "/", options.base).toString(),
|
|
265
|
+
feed_url: new URL(options.feedPath ?? "/feed.json", options.base).toString(),
|
|
266
|
+
description: options.description,
|
|
267
|
+
items: Arr.map(entries, (entry) => feedItem(options.base, entry))
|
|
268
|
+
});
|
|
269
|
+
/** One item; `title` / `summary` are added only when the entry has them. */
|
|
270
|
+
const feedItem = (base, entry) => {
|
|
271
|
+
const url = new URL(entry.path, base).toString();
|
|
272
|
+
const item = {
|
|
273
|
+
id: url,
|
|
274
|
+
url,
|
|
275
|
+
date_published: entry.publishedAt,
|
|
276
|
+
date_modified: entry.updatedAt
|
|
277
|
+
};
|
|
278
|
+
if (entry.title !== null && entry.title !== void 0) item.title = entry.title;
|
|
279
|
+
if (entry.summary !== null && entry.summary !== void 0) item.summary = entry.summary;
|
|
280
|
+
return item;
|
|
281
|
+
};
|
|
282
|
+
/**
|
|
283
|
+
* The `Content-Type` a JSON Feed is served with.
|
|
284
|
+
*
|
|
285
|
+
* @since 0.1.0
|
|
286
|
+
* @category constants
|
|
287
|
+
*/
|
|
288
|
+
const JSON_FEED_CONTENT_TYPE = "application/feed+json";
|
|
289
|
+
/**
|
|
290
|
+
* A JSON Feed as an `HttpServerResponse`: 2-space pretty-printed (a
|
|
291
|
+
* hand-readable discovery document; changing the indent changes the bytes on
|
|
292
|
+
* the wire and in every cached copy), `application/feed+json`,
|
|
293
|
+
* {@link LOW_CHURN_CACHE_CONTROL} and, when given, the `Cache-Tag`.
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* ```ts
|
|
297
|
+
* import * as HttpServerResponse from "effect/http/HttpServerResponse"
|
|
298
|
+
* import { buildJsonFeed, FEED_CACHE_TAG, jsonFeedResponse } from "@thomasfosterau/effect-sitemap"
|
|
299
|
+
*
|
|
300
|
+
* const feed = buildJsonFeed({ base: "https://example.com", title: "t", description: "d" }, [])
|
|
301
|
+
* const response = jsonFeedResponse(feed, { cacheTag: FEED_CACHE_TAG })
|
|
302
|
+
* response.headers["content-type"] // "application/feed+json"
|
|
303
|
+
* const text = await HttpServerResponse.toWeb(response).text()
|
|
304
|
+
* text.startsWith("{\n \"version\"") // true
|
|
305
|
+
* ```
|
|
306
|
+
*
|
|
307
|
+
* @since 0.1.0
|
|
308
|
+
* @category feed
|
|
309
|
+
*/
|
|
310
|
+
const jsonFeedResponse = (feed, options = {}) => HttpServerResponse.text(JSON.stringify(feed, null, 2), {
|
|
311
|
+
contentType: JSON_FEED_CONTENT_TYPE,
|
|
312
|
+
headers: documentHeaders(options)
|
|
313
|
+
});
|
|
314
|
+
/**
|
|
315
|
+
* The `Cache-Tag` on every `feed.json` response.
|
|
316
|
+
*
|
|
317
|
+
* @since 0.1.0
|
|
318
|
+
* @category constants
|
|
319
|
+
*/
|
|
320
|
+
const FEED_CACHE_TAG = "feed";
|
|
321
|
+
/**
|
|
322
|
+
* The `Cache-Tag` on every `sitemap.xml` response.
|
|
323
|
+
*
|
|
324
|
+
* @since 0.1.0
|
|
325
|
+
* @category constants
|
|
326
|
+
*/
|
|
327
|
+
const SITEMAP_CACHE_TAG = "sitemap";
|
|
328
|
+
/**
|
|
329
|
+
* Builds a `purgeTagsForResourceType`: the tags a mutation to one resource
|
|
330
|
+
* of `type` may have staled — both aggregate documents when the type can
|
|
331
|
+
* appear in them, nothing otherwise. Deliberately decided from the type
|
|
332
|
+
* alone, with no per-request visibility check: a write to an always-private
|
|
333
|
+
* resource of an eligible type still purges — a wasted purge, not a missed
|
|
334
|
+
* one. `eligibleTypes` are the resource-type segments the documents
|
|
335
|
+
* enumerate against (the values of the application's route-segment map).
|
|
336
|
+
*
|
|
337
|
+
* @example
|
|
338
|
+
* ```ts
|
|
339
|
+
* import { FEED_CACHE_TAG, makePurgeTagsForResourceType, SITEMAP_CACHE_TAG } from "@thomasfosterau/effect-sitemap"
|
|
340
|
+
*
|
|
341
|
+
* const purgeTagsForResourceType = makePurgeTagsForResourceType(["posts", "people"])
|
|
342
|
+
* purgeTagsForResourceType("posts") // [SITEMAP_CACHE_TAG, FEED_CACHE_TAG]
|
|
343
|
+
* purgeTagsForResourceType("comments") // []
|
|
344
|
+
* ```
|
|
345
|
+
*
|
|
346
|
+
* @since 0.1.0
|
|
347
|
+
* @category purging
|
|
348
|
+
*/
|
|
349
|
+
const makePurgeTagsForResourceType = (eligibleTypes, tags = [SITEMAP_CACHE_TAG, FEED_CACHE_TAG]) => {
|
|
350
|
+
const eligible = HashSet.fromIterable(eligibleTypes);
|
|
351
|
+
return (type) => HashSet.has(eligible, type) ? tags : [];
|
|
352
|
+
};
|
|
353
|
+
/**
|
|
354
|
+
* Purges the given `Cache-Tag`s from THIS Worker's Workers Cache (a
|
|
355
|
+
* `purge({ tags })` purges only the calling Worker's own cache). A no-op
|
|
356
|
+
* for an empty tag list or an absent `ctx.cache`; a logged, never failing,
|
|
357
|
+
* outcome otherwise — the purge call rejecting, or *resolving* with
|
|
358
|
+
* `success: false` (a Cloudflare-side failure resolves rather than rejects).
|
|
359
|
+
* This is the purge half of PERF-CACHE-002: a mutation purges by
|
|
360
|
+
* `Cache-Tag` rather than waiting out the TTL. A dropped purge must never
|
|
361
|
+
* surface on the mutation that triggered it: the surface serves stale
|
|
362
|
+
* content until its own `s-maxage` elapses, the accepted failure mode.
|
|
363
|
+
*
|
|
364
|
+
* @example
|
|
365
|
+
* ```ts
|
|
366
|
+
* import * as Effect from "effect/Effect"
|
|
367
|
+
* import { purgeCacheTags } from "@thomasfosterau/effect-sitemap"
|
|
368
|
+
*
|
|
369
|
+
* const purged: Array<ReadonlyArray<string>> = []
|
|
370
|
+
* const ctx = { cache: { purge: async ({ tags }: { tags: Array<string> }) => { purged.push(tags); return { success: true, errors: [] } } } }
|
|
371
|
+
* await Effect.runPromise(purgeCacheTags(ctx, ["sitemap", "feed"]))
|
|
372
|
+
* await Effect.runPromise(purgeCacheTags(undefined, ["sitemap"])) // no cache: no-op
|
|
373
|
+
* purged // [["sitemap", "feed"]]
|
|
374
|
+
* ```
|
|
375
|
+
*
|
|
376
|
+
* @since 0.1.0
|
|
377
|
+
* @category purging
|
|
378
|
+
*/
|
|
379
|
+
const purgeCacheTags = (ctx, tags) => {
|
|
380
|
+
if (Arr.isReadonlyArrayEmpty(tags)) return Effect.void;
|
|
381
|
+
const cache = ctx?.cache;
|
|
382
|
+
if (cache === void 0) return Effect.void;
|
|
383
|
+
return Effect.tryPromise({
|
|
384
|
+
try: () => cache.purge({ tags: [...tags] }),
|
|
385
|
+
catch: (cause) => describeError(cause)
|
|
386
|
+
}).pipe(Effect.flatMap((result) => result.success ? Effect.void : logErrorEvent("cache-purge.rejected", {
|
|
387
|
+
tags,
|
|
388
|
+
errors: result.errors
|
|
389
|
+
})), Effect.catch((cause) => logErrorEvent("cache-purge.failed", {
|
|
390
|
+
tags,
|
|
391
|
+
cause
|
|
392
|
+
})));
|
|
393
|
+
};
|
|
394
|
+
/**
|
|
395
|
+
* {@link purgeCacheTags} as a `Promise`, for the plain-JS seam a front
|
|
396
|
+
* door hands to `ctx.waitUntil` after the response has gone out.
|
|
397
|
+
*
|
|
398
|
+
* @example
|
|
399
|
+
* ```ts
|
|
400
|
+
* import { purgeCacheTagsPromise } from "@thomasfosterau/effect-sitemap"
|
|
401
|
+
*
|
|
402
|
+
* await purgeCacheTagsPromise(undefined, ["sitemap"]) // resolves; nothing to purge against
|
|
403
|
+
* ```
|
|
404
|
+
*
|
|
405
|
+
* @since 0.1.0
|
|
406
|
+
* @category purging
|
|
407
|
+
*/
|
|
408
|
+
const purgeCacheTagsPromise = (ctx, tags) => Effect.runPromise(purgeCacheTags(ctx, tags));
|
|
409
|
+
//#endregion
|
|
410
|
+
export { FEED_CACHE_TAG, FEED_ITEM_LIMIT, JSON_FEED_CONTENT_TYPE, LOW_CHURN_CACHE_CONTROL, SITEMAP_CACHE_TAG, SITEMAP_MAX_URLS, URLSET_CLOSE, URLSET_OPEN, buildJsonFeed, chunkSitemap, escapeXml, jsonFeedResponse, lastmodOf, makePurgeTagsForResourceType, purgeCacheTags, purgeCacheTagsPromise, renderSitemap, renderSitemapIndex, renderUrl, sitemapResponse, sitemapStream };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://www.schemastore.org/package",
|
|
3
3
|
"name": "@thomasfosterau/effect-sitemap",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.1.0",
|
|
5
5
|
"description": "Sitemap, sitemap index and JSON Feed 1.1 builders for Effect web applications (rendered or streamed), the low-churn edge cache policy they share, and the Cache-Tag purge-on-mutation seam.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"effect",
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@thomasfosterau/effect-web": "^0.
|
|
40
|
+
"@thomasfosterau/effect-web": "^0.1.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@effect/language-service": "^0.87.4",
|
|
44
|
-
"@types/node": "^26.6.
|
|
44
|
+
"@types/node": "^26.6.5",
|
|
45
45
|
"@vitest/coverage-istanbul": "4.1.11",
|
|
46
46
|
"effect": "4.0.3"
|
|
47
47
|
},
|