@reactionary/feeds 0.0.1

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.
Files changed (40) hide show
  1. package/README.md +257 -0
  2. package/bin/reactionary-feeds +2 -0
  3. package/cli.cjs +170941 -0
  4. package/cli.js +340 -0
  5. package/documentation/basic-product-feeds.config.mjs +32 -0
  6. package/documentation/multilingual-feeds.config.mjs +59 -0
  7. package/documentation/nextjs-route-example.mjs +31 -0
  8. package/documentation/sitemaps.config.mjs +77 -0
  9. package/index.js +13 -0
  10. package/lib/default-registry.js +18 -0
  11. package/lib/env-client-builder.js +244 -0
  12. package/lib/feed-generator.js +395 -0
  13. package/lib/feed-server.js +305 -0
  14. package/lib/feed-transformer.js +21 -0
  15. package/lib/feed-types.js +45 -0
  16. package/lib/load-cwd-env.js +16 -0
  17. package/lib/sitemap-generator.js +173 -0
  18. package/lib/sitemap-xml.js +32 -0
  19. package/lib/transformers/acp-product-feed.transformer.js +128 -0
  20. package/lib/transformers/google-merchant-feed.transformer.js +60 -0
  21. package/lib/transformers/pricerunner-feed.transformer.js +60 -0
  22. package/lib/transformers/shared.js +25 -0
  23. package/lib/transformers/sitemap-feed.transformer.js +26 -0
  24. package/package.json +32 -0
  25. package/src/cli.d.ts +2 -0
  26. package/src/index.d.ts +13 -0
  27. package/src/lib/default-registry.d.ts +2 -0
  28. package/src/lib/env-client-builder.d.ts +18 -0
  29. package/src/lib/feed-generator.d.ts +17 -0
  30. package/src/lib/feed-server.d.ts +28 -0
  31. package/src/lib/feed-transformer.d.ts +30 -0
  32. package/src/lib/feed-types.d.ts +185 -0
  33. package/src/lib/load-cwd-env.d.ts +4 -0
  34. package/src/lib/sitemap-generator.d.ts +20 -0
  35. package/src/lib/sitemap-xml.d.ts +3 -0
  36. package/src/lib/transformers/acp-product-feed.transformer.d.ts +4 -0
  37. package/src/lib/transformers/google-merchant-feed.transformer.d.ts +2 -0
  38. package/src/lib/transformers/pricerunner-feed.transformer.d.ts +2 -0
  39. package/src/lib/transformers/shared.d.ts +6 -0
  40. package/src/lib/transformers/sitemap-feed.transformer.d.ts +2 -0
@@ -0,0 +1,305 @@
1
+ import {
2
+ createInitialRequestContext
3
+ } from "@reactionary/core";
4
+ import { createDefaultFeedRegistry } from "./default-registry.js";
5
+ import { ReactionaryFeedGenerator } from "./feed-generator.js";
6
+ import { ReactionarySitemapGenerator } from "./sitemap-generator.js";
7
+ import {
8
+ toSitemapIndexXml,
9
+ toSitemapXml
10
+ } from "./sitemap-xml.js";
11
+ class ReactionaryFeedServer {
12
+ constructor(clientFactory, options) {
13
+ this.clientFactory = clientFactory;
14
+ this.options = options;
15
+ this.registry = options.registry ?? createDefaultFeedRegistry();
16
+ }
17
+ clientFactory;
18
+ options;
19
+ registry;
20
+ async fetch(request) {
21
+ try {
22
+ return await this.handleRequest(request);
23
+ } catch (error) {
24
+ return jsonResponse({
25
+ error: error instanceof Error ? error.message : String(error)
26
+ }, { status: 500 });
27
+ }
28
+ }
29
+ getHandler() {
30
+ return {
31
+ fetch: (request) => this.fetch(request),
32
+ close: () => this.close()
33
+ };
34
+ }
35
+ toNodeHandler() {
36
+ return async (request, response) => {
37
+ const webResponse = await this.fetch(await toWebRequest(request));
38
+ await sendWebResponse(response, webResponse);
39
+ };
40
+ }
41
+ close() {
42
+ return Promise.resolve();
43
+ }
44
+ async handleRequest(request) {
45
+ if (request.method !== "GET" && request.method !== "HEAD") {
46
+ return jsonResponse({
47
+ error: `Unsupported method: ${request.method}`
48
+ }, {
49
+ status: 405,
50
+ headers: { allow: "GET, HEAD" }
51
+ });
52
+ }
53
+ const route = parseFeedRoute(request, this.options.basePath);
54
+ const sitemapRoute = parseSitemapRoute(request, this.options.sitemaps);
55
+ if (sitemapRoute.kind === "index") {
56
+ return this.generateSitemapIndexResponse(request);
57
+ }
58
+ if (sitemapRoute.kind === "source") {
59
+ return this.generateSitemapResponse(request, sitemapRoute.sourceId);
60
+ }
61
+ if (route.kind === "feeds") {
62
+ return jsonResponse({
63
+ feeds: Object.keys(this.options.feeds)
64
+ }, { omitBody: request.method === "HEAD" });
65
+ }
66
+ if (route.kind === "transformers") {
67
+ return jsonResponse({
68
+ transformers: this.registry.list()
69
+ }, { omitBody: request.method === "HEAD" });
70
+ }
71
+ if (route.kind !== "output") {
72
+ return jsonResponse({
73
+ error: "Not found"
74
+ }, { status: 404, omitBody: request.method === "HEAD" });
75
+ }
76
+ const feed = this.options.feeds[route.feedId];
77
+ const transformer = this.registry.get(route.transformerId);
78
+ if (!feed) {
79
+ return jsonResponse({
80
+ error: `Feed not found: ${route.feedId}`
81
+ }, { status: 404 });
82
+ }
83
+ if (!transformer) {
84
+ return jsonResponse({
85
+ error: `Feed transformer not found: ${route.transformerId}`
86
+ }, { status: 404 });
87
+ }
88
+ return this.generateFeedResponse(
89
+ request,
90
+ route.feedId,
91
+ feed,
92
+ transformer
93
+ );
94
+ }
95
+ generateFeedResponse(request, feedId, feed, transformer) {
96
+ const requestContext = createInitialRequestContext();
97
+ requestContext.languageContext = feed.languageContext;
98
+ const client = this.clientFactory(requestContext);
99
+ const generator = new ReactionaryFeedGenerator(client, {
100
+ defaultFulfillmentCenterKeys: this.options.defaultFulfillmentCenterKeys,
101
+ productConcurrency: this.options.productConcurrency
102
+ });
103
+ const output = transformer.transform(
104
+ generator.products(feed, requestContext),
105
+ {
106
+ feedId,
107
+ feed,
108
+ options: transformer.defaultOptions
109
+ }
110
+ );
111
+ return new Response(
112
+ request.method === "HEAD" ? null : iterableToStream(output),
113
+ {
114
+ headers: {
115
+ "content-type": transformer.output.contentType,
116
+ "content-disposition": `attachment; filename="${feedId}.${transformer.output.fileExtension}"`
117
+ }
118
+ }
119
+ );
120
+ }
121
+ async generateSitemapIndexResponse(request) {
122
+ const sitemaps = this.options.sitemaps;
123
+ if (!sitemaps) {
124
+ return jsonResponse({
125
+ error: "Sitemaps are not configured."
126
+ }, { status: 404, omitBody: request.method === "HEAD" });
127
+ }
128
+ const baseUrl = trimTrailingSlash(sitemaps.baseUrl);
129
+ const sourceIds = getIncludedSitemapSourceIds(sitemaps);
130
+ const xml = await toSitemapIndexXml(
131
+ sourceIds.map((sourceId) => `${baseUrl}/sitemaps/${encodeURIComponent(sourceId)}.xml`)
132
+ );
133
+ return xmlResponse(xml, {
134
+ omitBody: request.method === "HEAD"
135
+ });
136
+ }
137
+ async generateSitemapResponse(request, sourceId) {
138
+ const sitemaps = this.options.sitemaps;
139
+ if (!sitemaps) {
140
+ return jsonResponse({
141
+ error: "Sitemaps are not configured."
142
+ }, { status: 404, omitBody: request.method === "HEAD" });
143
+ }
144
+ if (!getIncludedSitemapSourceIds(sitemaps).includes(sourceId)) {
145
+ return jsonResponse({
146
+ error: `Sitemap source not found: ${sourceId}`
147
+ }, { status: 404, omitBody: request.method === "HEAD" });
148
+ }
149
+ const source = sitemaps.sources[sourceId];
150
+ if (!source) {
151
+ return jsonResponse({
152
+ error: `Sitemap source not found: ${sourceId}`
153
+ }, { status: 404, omitBody: request.method === "HEAD" });
154
+ }
155
+ const requestContext = createInitialRequestContext();
156
+ const languageContext = source.type === "products" ? this.options.feeds[source.feed]?.languageContext : source.languageContext;
157
+ if (languageContext) {
158
+ requestContext.languageContext = languageContext;
159
+ }
160
+ const client = this.clientFactory(requestContext);
161
+ const generator = new ReactionarySitemapGenerator(
162
+ client,
163
+ this.options.feeds,
164
+ {
165
+ defaultFulfillmentCenterKeys: this.options.defaultFulfillmentCenterKeys,
166
+ productConcurrency: this.options.productConcurrency
167
+ }
168
+ );
169
+ const xml = await toSitemapXml(generator.entries(source, requestContext));
170
+ return xmlResponse(xml, {
171
+ headers: {
172
+ "content-disposition": `attachment; filename="${sourceId}.xml"`
173
+ },
174
+ omitBody: request.method === "HEAD"
175
+ });
176
+ }
177
+ }
178
+ function parseSitemapRoute(request, sitemaps) {
179
+ if (!sitemaps) {
180
+ return { kind: "not-found" };
181
+ }
182
+ const pathname = new URL(request.url).pathname;
183
+ if (pathname === "/sitemaps.xml") {
184
+ return { kind: "index" };
185
+ }
186
+ const match = /^\/sitemaps\/([^/]+)\.xml$/.exec(pathname);
187
+ if (match) {
188
+ return {
189
+ kind: "source",
190
+ sourceId: decodeURIComponent(match[1] ?? "")
191
+ };
192
+ }
193
+ return { kind: "not-found" };
194
+ }
195
+ function parseFeedRoute(request, basePath = "/feeds") {
196
+ const pathname = getProtocolPathname(request, basePath);
197
+ if (pathname === "/" || pathname === "") {
198
+ return { kind: "feeds" };
199
+ }
200
+ if (pathname === "/transformers") {
201
+ return { kind: "transformers" };
202
+ }
203
+ const match = /^\/([^/]+)\/outputs\/([^/]+)$/.exec(pathname);
204
+ if (match) {
205
+ return {
206
+ kind: "output",
207
+ feedId: decodeURIComponent(match[1] ?? ""),
208
+ transformerId: decodeURIComponent(match[2] ?? "")
209
+ };
210
+ }
211
+ return { kind: "not-found" };
212
+ }
213
+ function getProtocolPathname(request, basePath) {
214
+ const pathname = new URL(request.url).pathname;
215
+ if (pathname === basePath) {
216
+ return "/";
217
+ }
218
+ if (pathname.startsWith(`${basePath}/`)) {
219
+ return pathname.slice(basePath.length);
220
+ }
221
+ return pathname;
222
+ }
223
+ function iterableToStream(iterable) {
224
+ const encoder = new TextEncoder();
225
+ const iterator = iterable[Symbol.asyncIterator]();
226
+ return new ReadableStream({
227
+ async pull(controller) {
228
+ const next = await iterator.next();
229
+ if (next.done) {
230
+ controller.close();
231
+ return;
232
+ }
233
+ controller.enqueue(
234
+ typeof next.value === "string" ? encoder.encode(next.value) : next.value
235
+ );
236
+ },
237
+ async cancel() {
238
+ await iterator.return?.();
239
+ }
240
+ });
241
+ }
242
+ function jsonResponse(body, options = {}) {
243
+ return new Response(options.omitBody ? null : JSON.stringify(body), {
244
+ status: options.status ?? 200,
245
+ headers: {
246
+ "content-type": "application/json; charset=utf-8",
247
+ ...options.headers
248
+ }
249
+ });
250
+ }
251
+ function xmlResponse(body, options = {}) {
252
+ return new Response(options.omitBody ? null : body, {
253
+ headers: {
254
+ "content-type": "application/xml; charset=utf-8",
255
+ ...options.headers
256
+ }
257
+ });
258
+ }
259
+ function getIncludedSitemapSourceIds(sitemaps) {
260
+ return sitemaps.include ?? Object.keys(sitemaps.sources);
261
+ }
262
+ function trimTrailingSlash(value) {
263
+ return value.replace(/\/+$/, "");
264
+ }
265
+ async function toWebRequest(request) {
266
+ const headers = toWebHeaders(request.headers);
267
+ const url = new URL(
268
+ request.url ?? "/",
269
+ `http://${request.headers.host ?? "localhost"}`
270
+ );
271
+ return new Request(url, {
272
+ method: request.method,
273
+ headers
274
+ });
275
+ }
276
+ function toWebHeaders(headers) {
277
+ const webHeaders = new Headers();
278
+ for (const [key, value] of Object.entries(headers)) {
279
+ if (value === void 0) {
280
+ continue;
281
+ }
282
+ if (Array.isArray(value)) {
283
+ for (const entry of value) {
284
+ webHeaders.append(key, entry);
285
+ }
286
+ continue;
287
+ }
288
+ webHeaders.set(key, value);
289
+ }
290
+ return webHeaders;
291
+ }
292
+ async function sendWebResponse(response, webResponse) {
293
+ response.writeHead(
294
+ webResponse.status,
295
+ Object.fromEntries(webResponse.headers.entries())
296
+ );
297
+ if (webResponse.body) {
298
+ response.end(Buffer.from(await webResponse.arrayBuffer()));
299
+ return;
300
+ }
301
+ response.end();
302
+ }
303
+ export {
304
+ ReactionaryFeedServer
305
+ };
@@ -0,0 +1,21 @@
1
+ class ReactionaryFeedTransformerRegistry {
2
+ transformers = /* @__PURE__ */ new Map();
3
+ register(transformer) {
4
+ this.transformers.set(transformer.id, transformer);
5
+ }
6
+ get(id) {
7
+ return this.transformers.get(id);
8
+ }
9
+ list() {
10
+ return [...this.transformers.values()].map((transformer) => ({
11
+ id: transformer.id,
12
+ title: transformer.title,
13
+ description: transformer.description,
14
+ contentType: transformer.output.contentType,
15
+ fileExtension: transformer.output.fileExtension
16
+ }));
17
+ }
18
+ }
19
+ export {
20
+ ReactionaryFeedTransformerRegistry
21
+ };
@@ -0,0 +1,45 @@
1
+ function assertReactionaryFeedClient(client) {
2
+ const missing = getMissingFeedClientOperations(client);
3
+ if (missing.length > 0) {
4
+ throw new Error(
5
+ `Reactionary feed generator cannot initialize because the client is missing required operations: ${missing.join(", ")}`
6
+ );
7
+ }
8
+ }
9
+ function getMissingFeedClientOperations(client) {
10
+ return [
11
+ ["productSearch.queryByTerm", client.productSearch?.queryByTerm],
12
+ ["product.getBySKU", client.product?.getBySKU],
13
+ ["price.getCustomerPrice", client.price?.getCustomerPrice],
14
+ ["price.getListPrice", client.price?.getListPrice],
15
+ ["inventory.getBySKU", client.inventory?.getBySKU]
16
+ ].filter(([, operation]) => typeof operation !== "function").map(([name]) => name);
17
+ }
18
+ function getMissingSitemapClientOperations(client, source) {
19
+ if (source.type === "products") {
20
+ return getMissingFeedClientOperations(client);
21
+ }
22
+ if (source.type === "categories") {
23
+ return [
24
+ ["category.findTopCategories", client.category?.findTopCategories],
25
+ ["category.findChildCategories", client.category?.findChildCategories]
26
+ ].filter(([, operation]) => typeof operation !== "function").map(([name]) => name);
27
+ }
28
+ return [
29
+ ["store.queryByProximity", client.store?.queryByProximity]
30
+ ].filter(([, operation]) => typeof operation !== "function").map(([name]) => name);
31
+ }
32
+ function assertReactionarySitemapClient(client, source) {
33
+ const missing = getMissingSitemapClientOperations(client, source);
34
+ if (missing.length > 0) {
35
+ throw new Error(
36
+ `Reactionary sitemap source cannot initialize because the client is missing required operations: ${missing.join(", ")}`
37
+ );
38
+ }
39
+ }
40
+ export {
41
+ assertReactionaryFeedClient,
42
+ assertReactionarySitemapClient,
43
+ getMissingFeedClientOperations,
44
+ getMissingSitemapClientOperations
45
+ };
@@ -0,0 +1,16 @@
1
+ import { config } from "dotenv";
2
+ function loadCwdEnv(options = {}) {
3
+ const env = options.env ?? process.env;
4
+ const result = env["DOTENV_CONFIG_PATH"] ? config({ path: env["DOTENV_CONFIG_PATH"] }) : config();
5
+ if (!result.error) {
6
+ return;
7
+ }
8
+ const errorCode = result.error.code;
9
+ if (errorCode === "ENOENT" && env["DOTENV_CONFIG_PATH"] === void 0) {
10
+ return;
11
+ }
12
+ throw result.error;
13
+ }
14
+ export {
15
+ loadCwdEnv
16
+ };
@@ -0,0 +1,173 @@
1
+ import { ReactionaryFeedGenerator } from "./feed-generator.js";
2
+ import {
3
+ assertReactionarySitemapClient
4
+ } from "./feed-types.js";
5
+ class ReactionarySitemapGenerator {
6
+ constructor(client, feeds, options = {}) {
7
+ this.client = client;
8
+ this.feeds = feeds;
9
+ this.options = options;
10
+ }
11
+ client;
12
+ feeds;
13
+ options;
14
+ async *entries(source, requestContext) {
15
+ assertReactionarySitemapClient(this.client, source);
16
+ if (source.type === "products") {
17
+ yield* this.productEntries(source.feed, source, requestContext);
18
+ return;
19
+ }
20
+ requestContext.languageContext = source.languageContext;
21
+ if (source.type === "categories") {
22
+ yield* this.categoryEntries(source);
23
+ return;
24
+ }
25
+ yield* this.storeEntries(source);
26
+ }
27
+ async *productEntries(feedId, source, requestContext) {
28
+ const feed = this.feeds[feedId];
29
+ if (!feed) {
30
+ throw new Error(`Product sitemap feed not found: ${feedId}`);
31
+ }
32
+ const generator = new ReactionaryFeedGenerator(this.client, this.options);
33
+ for await (const product of generator.products(feed, requestContext)) {
34
+ if (!product.url) {
35
+ continue;
36
+ }
37
+ yield {
38
+ url: product.url,
39
+ changefreq: source.changefreq,
40
+ priority: source.priority
41
+ };
42
+ }
43
+ }
44
+ async *categoryEntries(source) {
45
+ const client = this.client;
46
+ if (!client.category) {
47
+ return;
48
+ }
49
+ const topCategories = this.iterCategoryPageResults(
50
+ (pageNumber, pageSize) => client.category?.findTopCategories({
51
+ paginationOptions: {
52
+ pageNumber,
53
+ pageSize
54
+ }
55
+ }) ?? Promise.resolve({
56
+ success: true,
57
+ value: emptyCategoryPage(pageNumber, pageSize)
58
+ }),
59
+ source
60
+ );
61
+ for await (const category of topCategories) {
62
+ yield* this.categoryWithChildrenEntries(category, source, 1);
63
+ }
64
+ }
65
+ async *categoryWithChildrenEntries(category, source, depth) {
66
+ const url = toTemplatedUrl(source.urlTemplate, source.languageContext, {
67
+ id: category.identifier.key,
68
+ slug: category.slug || category.identifier.key
69
+ });
70
+ if (url) {
71
+ yield {
72
+ url,
73
+ changefreq: source.changefreq,
74
+ priority: source.priority
75
+ };
76
+ }
77
+ if (source.includeChildren === false || depth >= (source.maxDepth ?? 10)) {
78
+ return;
79
+ }
80
+ const client = this.client;
81
+ if (!client.category) {
82
+ return;
83
+ }
84
+ const childCategories = this.iterCategoryPageResults(
85
+ (pageNumber, pageSize) => client.category?.findChildCategories({
86
+ parentId: category.identifier,
87
+ paginationOptions: {
88
+ pageNumber,
89
+ pageSize
90
+ }
91
+ }) ?? Promise.resolve({
92
+ success: true,
93
+ value: emptyCategoryPage(pageNumber, pageSize)
94
+ }),
95
+ source
96
+ );
97
+ for await (const child of childCategories) {
98
+ yield* this.categoryWithChildrenEntries(child, source, depth + 1);
99
+ }
100
+ }
101
+ async *iterCategoryPageResults(loadPage, source) {
102
+ const pageSize = source.pageSize ?? 50;
103
+ const maxPages = source.maxPagesPerLevel ?? 100;
104
+ let pageNumber = 1;
105
+ while (pageNumber <= maxPages) {
106
+ const result = await loadPage(pageNumber, pageSize);
107
+ if (!result.success) {
108
+ throw new Error(JSON.stringify(result.error));
109
+ }
110
+ for (const category of result.value.items) {
111
+ yield category;
112
+ }
113
+ if (pageNumber >= result.value.totalPages || result.value.items.length === 0) {
114
+ return;
115
+ }
116
+ pageNumber += 1;
117
+ }
118
+ }
119
+ async *storeEntries(source) {
120
+ const result = await this.client.store?.queryByProximity(source.proximity);
121
+ if (!result) {
122
+ return;
123
+ }
124
+ if (!result.success) {
125
+ throw new Error(JSON.stringify(result.error));
126
+ }
127
+ for (const store of result.value) {
128
+ const url = toTemplatedUrl(source.urlTemplate, source.languageContext, {
129
+ id: store.identifier.key,
130
+ slug: slugifyStore(store)
131
+ });
132
+ if (!url) {
133
+ continue;
134
+ }
135
+ yield {
136
+ url,
137
+ changefreq: source.changefreq,
138
+ priority: source.priority
139
+ };
140
+ }
141
+ }
142
+ }
143
+ function toTemplatedUrl(urlTemplate, languageContext, values) {
144
+ const slug = values.slug ?? values.id;
145
+ if (!urlTemplate) {
146
+ return void 0;
147
+ }
148
+ if (urlTemplate.includes("{lang}") || urlTemplate.includes("{slug}") || urlTemplate.includes("{id}")) {
149
+ return urlTemplate.replaceAll("{lang}", getLanguage(languageContext)).replaceAll("{slug}", encodeURIComponent(slug)).replaceAll("{id}", encodeURIComponent(values.id));
150
+ }
151
+ return new URL(slug, urlTemplate).href;
152
+ }
153
+ function getLanguage(languageContext) {
154
+ const [language] = languageContext.locale.split("-");
155
+ return language.toLowerCase();
156
+ }
157
+ function slugifyStore(store) {
158
+ const slug = store.name.trim().toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-|-$/g, "");
159
+ return slug || store.identifier.key;
160
+ }
161
+ function emptyCategoryPage(pageNumber, pageSize) {
162
+ return {
163
+ pageNumber,
164
+ pageSize,
165
+ totalCount: 0,
166
+ totalPages: 0,
167
+ items: []
168
+ };
169
+ }
170
+ export {
171
+ ReactionarySitemapGenerator,
172
+ toTemplatedUrl
173
+ };
@@ -0,0 +1,32 @@
1
+ import {
2
+ SitemapIndexStream,
3
+ SitemapStream,
4
+ streamToPromise
5
+ } from "sitemap";
6
+ async function toSitemapXml(entries) {
7
+ const sitemap = new SitemapStream();
8
+ for await (const entry of entries) {
9
+ sitemap.write({
10
+ url: entry.url,
11
+ lastmod: entry.lastmod,
12
+ changefreq: entry.changefreq,
13
+ priority: entry.priority
14
+ });
15
+ }
16
+ sitemap.end();
17
+ return (await streamToPromise(sitemap)).toString();
18
+ }
19
+ async function toSitemapIndexXml(urls) {
20
+ const sitemapIndex = new SitemapIndexStream();
21
+ for (const url of urls) {
22
+ sitemapIndex.write({
23
+ url
24
+ });
25
+ }
26
+ sitemapIndex.end();
27
+ return (await streamToPromise(sitemapIndex)).toString();
28
+ }
29
+ export {
30
+ toSitemapIndexXml,
31
+ toSitemapXml
32
+ };