@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
package/cli.js ADDED
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { createWriteStream } from "node:fs";
4
+ import { mkdir } from "node:fs/promises";
5
+ import { dirname, resolve } from "node:path";
6
+ import { finished } from "node:stream/promises";
7
+ import { pathToFileURL } from "node:url";
8
+ import { createDefaultFeedRegistry } from "./lib/default-registry.js";
9
+ import { ReactionaryFeedGenerator } from "./lib/feed-generator.js";
10
+ async function main() {
11
+ const program = new Command().name("reactionary-feeds").description("Generate product feeds from Reactionary clients.").version("0.0.1");
12
+ program.command("list-feeds").description("List feed ids from a feed config module.").requiredOption("-c, --config <path>", "Path to an ESM feed config module.").action(async (options) => {
13
+ const config = await loadConfig(options.config);
14
+ console.log(JSON.stringify({ feeds: Object.keys(config.feeds) }, null, 2));
15
+ });
16
+ program.command("list-transformers").description("List built-in feed output transformers.").requiredOption("-c, --config <path>", "Path to an ESM feed config module.").action(async (options) => {
17
+ await loadConfig(options.config);
18
+ const registry = createDefaultFeedRegistry();
19
+ console.log(JSON.stringify({ transformers: registry.list() }, null, 2));
20
+ });
21
+ program.command("generate").description("Generate one or more feed outputs in a single source pass.").requiredOption("-c, --config <path>", "Path to an ESM feed config module.").requiredOption("-f, --feed <id>", "Configured feed id to generate.").requiredOption(
22
+ "-o, --output <transformer=path>",
23
+ "Transformer/output pair. Repeat for each output file.",
24
+ collectOption,
25
+ []
26
+ ).option("--product-concurrency <count>", "Products to normalize in parallel. Defaults to config value or 10.").option("--testMode", "Limit source reads to at most 2 pages for smoke testing.").option("--no-progress", "Disable progress reporting on stderr.").action(async (options) => {
27
+ await generateFeed(options);
28
+ });
29
+ await program.parseAsync(process.argv);
30
+ }
31
+ async function generateFeed(options) {
32
+ const context = await createFeedGenerationContext(options, {
33
+ feedId: options.feed,
34
+ transformerLabel: "outputs"
35
+ });
36
+ const outputs = parseOutputSpecs(options.output, context.registry);
37
+ const productOutputs = fanoutAsyncIterable(
38
+ context.generator.products(context.feed, context.requestContext),
39
+ outputs.length
40
+ );
41
+ await Promise.all(outputs.map((output, index) => {
42
+ const products = productOutputs[index];
43
+ if (!products) {
44
+ throw new Error(`Missing product stream for output: ${output.transformerId}`);
45
+ }
46
+ return writeTransformerOutput({
47
+ feedId: options.feed,
48
+ feed: context.feed,
49
+ output,
50
+ products
51
+ });
52
+ }));
53
+ }
54
+ async function createFeedGenerationContext(options, labels) {
55
+ const config = await loadConfig(options.config);
56
+ const registry = createDefaultFeedRegistry();
57
+ const configuredFeed = config.feeds[labels.feedId];
58
+ if (!configuredFeed) {
59
+ throw new Error(`Feed not found: ${labels.feedId}`);
60
+ }
61
+ const feed = options.testMode ? withTestModeMaxPages(configuredFeed) : configuredFeed;
62
+ const requestContext = createCliRequestContext();
63
+ requestContext.languageContext = feed.languageContext;
64
+ const createClient = await getClientFactory(config);
65
+ const client = createClient(requestContext);
66
+ const progressReporter = options.progress === false ? void 0 : createCliProgressReporter({
67
+ feedId: labels.feedId,
68
+ transformerId: labels.transformerLabel,
69
+ stream: process.stderr
70
+ });
71
+ const generator = new ReactionaryFeedGenerator(client, {
72
+ defaultFulfillmentCenterKeys: config.defaultFulfillmentCenterKeys,
73
+ productConcurrency: parseProductConcurrency(
74
+ options.productConcurrency,
75
+ config.productConcurrency
76
+ ),
77
+ onProgress: progressReporter
78
+ });
79
+ return {
80
+ config,
81
+ feed,
82
+ registry,
83
+ requestContext,
84
+ generator
85
+ };
86
+ }
87
+ function withTestModeMaxPages(feed) {
88
+ return {
89
+ ...feed,
90
+ maxPages: Math.min(feed.maxPages ?? 2, 2)
91
+ };
92
+ }
93
+ async function writeTransformerOutput(input) {
94
+ await mkdir(dirname(resolve(input.output.outputPath)), { recursive: true });
95
+ const stream = createWriteStream(resolve(input.output.outputPath));
96
+ try {
97
+ for await (const chunk of input.output.transformer.transform(
98
+ input.products,
99
+ {
100
+ feedId: input.feedId,
101
+ feed: input.feed,
102
+ options: input.output.transformer.defaultOptions
103
+ }
104
+ )) {
105
+ if (!stream.write(chunk)) {
106
+ await onceDrain(stream);
107
+ }
108
+ }
109
+ } finally {
110
+ stream.end();
111
+ }
112
+ await finished(stream);
113
+ }
114
+ function parseOutputSpecs(values, registry) {
115
+ if (values.length === 0) {
116
+ throw new Error("At least one --output transformer=path pair is required.");
117
+ }
118
+ const seen = /* @__PURE__ */ new Set();
119
+ return values.map((value) => {
120
+ const separator = value.indexOf("=");
121
+ if (separator <= 0 || separator === value.length - 1) {
122
+ throw new Error(`Invalid --output value "${value}". Expected transformer=path.`);
123
+ }
124
+ const transformerId = value.slice(0, separator);
125
+ const outputPath = value.slice(separator + 1);
126
+ if (seen.has(transformerId)) {
127
+ throw new Error(`Duplicate transformer output: ${transformerId}`);
128
+ }
129
+ seen.add(transformerId);
130
+ const transformer = registry.get(transformerId);
131
+ if (!transformer) {
132
+ throw new Error(`Transformer not found: ${transformerId}`);
133
+ }
134
+ return {
135
+ transformerId,
136
+ outputPath,
137
+ transformer
138
+ };
139
+ });
140
+ }
141
+ function fanoutAsyncIterable(source, consumerCount) {
142
+ const maxQueuedItemsPerConsumer = 20;
143
+ const queues = Array.from({ length: consumerCount }, () => []);
144
+ const waiters = Array.from({ length: consumerCount }, () => []);
145
+ const capacityWaiters = [];
146
+ let done = false;
147
+ let error = void 0;
148
+ const producer = (async () => {
149
+ try {
150
+ for await (const item of source) {
151
+ while (queues.some((queue) => queue.length >= maxQueuedItemsPerConsumer)) {
152
+ await new Promise((resolve2) => {
153
+ capacityWaiters.push(resolve2);
154
+ });
155
+ }
156
+ for (const queue of queues) {
157
+ queue.push(item);
158
+ }
159
+ for (const queueWaiters of waiters) {
160
+ queueWaiters.splice(0).forEach((wake) => wake());
161
+ }
162
+ }
163
+ } catch (caught) {
164
+ error = caught;
165
+ } finally {
166
+ done = true;
167
+ for (const queueWaiters of waiters) {
168
+ queueWaiters.splice(0).forEach((wake) => wake());
169
+ }
170
+ }
171
+ })();
172
+ return queues.map((queue, index) => ({
173
+ async *[Symbol.asyncIterator]() {
174
+ while (true) {
175
+ if (queue.length > 0) {
176
+ const item = queue.shift();
177
+ capacityWaiters.splice(0).forEach((wake) => wake());
178
+ yield item;
179
+ continue;
180
+ }
181
+ if (done) {
182
+ if (error) {
183
+ throw error;
184
+ }
185
+ await producer;
186
+ return;
187
+ }
188
+ await new Promise((resolve2) => {
189
+ waiters[index].push(resolve2);
190
+ });
191
+ }
192
+ }
193
+ }));
194
+ }
195
+ function collectOption(value, previous) {
196
+ return [...previous, value];
197
+ }
198
+ async function onceDrain(stream) {
199
+ await new Promise((resolve2, reject) => {
200
+ stream.once("drain", resolve2);
201
+ stream.once("error", reject);
202
+ });
203
+ }
204
+ async function getClientFactory(config) {
205
+ if (config.createClient) {
206
+ return config.createClient;
207
+ }
208
+ const {
209
+ createReactionaryFeedClientFactoryFromEnv
210
+ } = await import("./lib/env-client-builder.js");
211
+ return createReactionaryFeedClientFactoryFromEnv();
212
+ }
213
+ function createCliRequestContext() {
214
+ return {
215
+ languageContext: {
216
+ locale: "da-DK",
217
+ currencyCode: "DKK"
218
+ },
219
+ storeIdentifier: {
220
+ key: "the-good-store"
221
+ },
222
+ taxJurisdiction: {
223
+ countryCode: "DK",
224
+ stateCode: "",
225
+ countyCode: "",
226
+ cityCode: ""
227
+ },
228
+ session: {
229
+ identityContext: {
230
+ identity: {
231
+ type: "Anonymous"
232
+ },
233
+ lastUpdated: /* @__PURE__ */ new Date()
234
+ },
235
+ marketingContext: {
236
+ identifier: { key: "" },
237
+ segments: [],
238
+ blurb: ""
239
+ }
240
+ },
241
+ correlationId: "",
242
+ isBot: false,
243
+ clientIp: "",
244
+ userAgent: "",
245
+ referrer: ""
246
+ };
247
+ }
248
+ async function loadConfig(configPath) {
249
+ const module = await import(pathToFileURL(resolve(configPath)).href);
250
+ if (!module.default) {
251
+ throw new Error(`Feed config must export a default object: ${configPath}`);
252
+ }
253
+ return module.default;
254
+ }
255
+ function createCliProgressReporter(options) {
256
+ const label = `${options.feedId}/${options.transformerId}`;
257
+ const throttleMs = options.stream.isTTY ? 100 : 5e3;
258
+ let lastRenderedAt = 0;
259
+ let lastLength = 0;
260
+ return (progress) => {
261
+ const now = Date.now();
262
+ const completed = progress.phase === "completed";
263
+ if (!completed && now - lastRenderedAt < throttleMs) {
264
+ return;
265
+ }
266
+ const line = formatProgressLine(label, progress);
267
+ if (options.stream.isTTY) {
268
+ options.stream.write(`\r${line.padEnd(lastLength)}`);
269
+ lastLength = line.length;
270
+ if (completed) {
271
+ options.stream.write("\n");
272
+ }
273
+ } else {
274
+ options.stream.write(`${line}
275
+ `);
276
+ }
277
+ lastRenderedAt = now;
278
+ };
279
+ }
280
+ function formatProgressLine(label, progress) {
281
+ const total = progress.totalProducts;
282
+ const processed = progress.processedProducts;
283
+ const percent = total === void 0 ? void 0 : total === 0 ? 1 : Math.min(processed / total, 1);
284
+ const count = total === void 0 ? `${processed}/?` : `${processed}/${total}`;
285
+ const page = progress.pageNumber && progress.totalPages ? ` page ${progress.pageNumber}/${progress.totalPages}` : "";
286
+ const status = progress.phase === "completed" ? "done" : progress.phase;
287
+ const eta = progress.phase === "completed" ? "0s" : formatEta(progress, percent);
288
+ return [
289
+ `${label}`,
290
+ formatProgressBar(percent),
291
+ count,
292
+ percent === void 0 ? "" : `${Math.round(percent * 100)}%`,
293
+ status,
294
+ page.trim(),
295
+ `elapsed ${formatDuration(progress.elapsedMs)}`,
296
+ `eta ${eta}`
297
+ ].filter(Boolean).join(" ");
298
+ }
299
+ function formatProgressBar(percent) {
300
+ const width = 24;
301
+ if (percent === void 0) {
302
+ return `[${"?".repeat(width)}]`;
303
+ }
304
+ const completed = Math.round(percent * width);
305
+ return `[${"#".repeat(completed)}${"-".repeat(width - completed)}]`;
306
+ }
307
+ function formatEta(progress, percent) {
308
+ if (percent === void 0 || progress.totalProducts === void 0 || progress.processedProducts === 0) {
309
+ return "estimating";
310
+ }
311
+ const remainingProducts = Math.max(
312
+ progress.totalProducts - progress.processedProducts,
313
+ 0
314
+ );
315
+ const msPerProduct = progress.elapsedMs / progress.processedProducts;
316
+ return formatDuration(msPerProduct * remainingProducts);
317
+ }
318
+ function formatDuration(milliseconds) {
319
+ const totalSeconds = Math.max(0, Math.round(milliseconds / 1e3));
320
+ const minutes = Math.floor(totalSeconds / 60);
321
+ const seconds = totalSeconds % 60;
322
+ if (minutes === 0) {
323
+ return `${seconds}s`;
324
+ }
325
+ return `${minutes}m ${seconds}s`;
326
+ }
327
+ function parseProductConcurrency(value, fallback) {
328
+ if (!value) {
329
+ return fallback;
330
+ }
331
+ const parsed = Number(value);
332
+ if (!Number.isFinite(parsed) || parsed < 1) {
333
+ throw new Error(`--product-concurrency must be a positive number, got: ${value}`);
334
+ }
335
+ return Math.floor(parsed);
336
+ }
337
+ main().catch((error) => {
338
+ console.error(error);
339
+ process.exit(1);
340
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Minimal example config for the `reactionary-feeds` CLI.
3
+ *
4
+ * Copy this file, configure provider environment variables in `.env`, and run:
5
+ *
6
+ * reactionary-feeds list-feeds --config ./feeds.config.mjs
7
+ * reactionary-feeds generate --config ./feeds.config.mjs --feed default --output google-merchant-feed=./google-merchant.xml
8
+ */
9
+
10
+ export default {
11
+ defaultFulfillmentCenterKeys: ['default'],
12
+ productConcurrency: 10,
13
+ feeds: {
14
+ default: {
15
+ languageContext: {
16
+ locale: 'en-GB',
17
+ currencyCode: 'EUR',
18
+ },
19
+ search: {
20
+ term: '',
21
+ facets: [],
22
+ filters: [],
23
+ paginationOptions: {
24
+ pageNumber: 1,
25
+ pageSize: 50,
26
+ },
27
+ },
28
+ productUrlBase: 'https://shop.example/{lang}/products/{slug}',
29
+ sellerName: 'Example Shop',
30
+ },
31
+ },
32
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Multilingual example config for da, nb, sv, fi, and en.
3
+ *
4
+ * Configure provider environment variables in `.env`, then run:
5
+ *
6
+ * reactionary-feeds list-feeds --config ./multilingual-feeds.config.mjs
7
+ * reactionary-feeds generate --config ./multilingual-feeds.config.mjs --feed da --output google-merchant-feed=./google-merchant-da.xml
8
+ * reactionary-feeds generate --config ./multilingual-feeds.config.mjs --feed fi --output acp-product-feed=./products-fi.jsonl --output sitemap-feed=./products-fi.xml
9
+ */
10
+
11
+ const languageFeeds = {
12
+ da: {
13
+ locale: 'da-DK',
14
+ currencyCode: 'DKK',
15
+ },
16
+ nb: {
17
+ locale: 'nb-NO',
18
+ currencyCode: 'NOK',
19
+ },
20
+ sv: {
21
+ locale: 'sv-SE',
22
+ currencyCode: 'SEK',
23
+ },
24
+ fi: {
25
+ locale: 'fi-FI',
26
+ currencyCode: 'EUR',
27
+ },
28
+ en: {
29
+ locale: 'en-GB',
30
+ currencyCode: 'GBP',
31
+ },
32
+ };
33
+
34
+ export default {
35
+ defaultFulfillmentCenterKeys: ['OnlineFfmChannel'],
36
+ productConcurrency: 10,
37
+ feeds: Object.fromEntries(
38
+ Object.entries(languageFeeds).map(([language, definition]) => [
39
+ language,
40
+ {
41
+ languageContext: {
42
+ locale: definition.locale,
43
+ currencyCode: definition.currencyCode,
44
+ },
45
+ search: {
46
+ term: '',
47
+ facets: [],
48
+ filters: [],
49
+ paginationOptions: {
50
+ pageNumber: 1,
51
+ pageSize: 50,
52
+ },
53
+ },
54
+ productUrlBase: `https://shop.example/${language}/products/{slug}`,
55
+ sellerName: 'Example Shop',
56
+ },
57
+ ]),
58
+ ),
59
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Example Next.js route module shape for mounting ReactionaryFeedServer.
3
+ *
4
+ * Copy the relevant parts into `app/feeds/[...path]/route.ts` or a similar
5
+ * catch-all route. Replace `createClient` and `feedsConfig` with your actual
6
+ * project setup.
7
+ */
8
+
9
+ import {
10
+ ReactionaryFeedServer,
11
+ createReactionaryFeedClientFactoryFromEnv,
12
+ } from '@reactionary/feeds';
13
+ import feedsConfig from './feeds.config.mjs';
14
+
15
+ const server = new ReactionaryFeedServer(
16
+ feedsConfig.createClient ?? createReactionaryFeedClientFactoryFromEnv(),
17
+ {
18
+ feeds: feedsConfig.feeds,
19
+ sitemaps: feedsConfig.sitemaps,
20
+ defaultFulfillmentCenterKeys: feedsConfig.defaultFulfillmentCenterKeys,
21
+ productConcurrency: feedsConfig.productConcurrency,
22
+ },
23
+ );
24
+
25
+ export function GET(request) {
26
+ return server.fetch(request);
27
+ }
28
+
29
+ export function HEAD(request) {
30
+ return server.fetch(request);
31
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Example config for generating product feed outputs and serving sitemap sources.
3
+ *
4
+ * Product-feed CLI outputs:
5
+ *
6
+ * reactionary-feeds generate --config ./feeds.config.mjs --feed finnish --output sitemap-feed=./products.xml
7
+ *
8
+ * Generic sitemap source routes are exposed by ReactionaryFeedServer:
9
+ *
10
+ * GET /sitemaps.xml
11
+ * GET /sitemaps/products-fi.xml
12
+ * GET /sitemaps/categories-fi.xml
13
+ * GET /sitemaps/stores-fi.xml
14
+ */
15
+
16
+ const finnishLanguageContext = {
17
+ locale: 'fi-FI',
18
+ currencyCode: 'EUR',
19
+ };
20
+
21
+ export default {
22
+ defaultFulfillmentCenterKeys: ['default'],
23
+ productConcurrency: 10,
24
+ feeds: {
25
+ finnish: {
26
+ languageContext: finnishLanguageContext,
27
+ search: {
28
+ term: '',
29
+ facets: [],
30
+ filters: ['market:fi'],
31
+ paginationOptions: {
32
+ pageNumber: 1,
33
+ pageSize: 50,
34
+ },
35
+ },
36
+ productUrlBase: 'https://shop.example/{lang}/products/{slug}',
37
+ sellerName: 'Example Shop',
38
+ },
39
+ },
40
+
41
+ sitemaps: {
42
+ baseUrl: 'https://shop.example',
43
+ include: ['products-fi', 'categories-fi', 'stores-fi'],
44
+ sources: {
45
+ 'products-fi': {
46
+ type: 'products',
47
+ feed: 'finnish',
48
+ changefreq: 'daily',
49
+ priority: 0.8,
50
+ },
51
+
52
+ 'categories-fi': {
53
+ type: 'categories',
54
+ languageContext: finnishLanguageContext,
55
+ urlTemplate: 'https://shop.example/{lang}/categories/{slug}',
56
+ pageSize: 50,
57
+ maxDepth: 10,
58
+ changefreq: 'weekly',
59
+ priority: 0.7,
60
+ },
61
+
62
+ 'stores-fi': {
63
+ type: 'stores',
64
+ languageContext: finnishLanguageContext,
65
+ urlTemplate: 'https://shop.example/{lang}/stores/{slug}',
66
+ proximity: {
67
+ longitude: 24.9384,
68
+ latitude: 60.1699,
69
+ distance: 250,
70
+ limit: 100,
71
+ },
72
+ changefreq: 'monthly',
73
+ priority: 0.5,
74
+ },
75
+ },
76
+ },
77
+ };
package/index.js ADDED
@@ -0,0 +1,13 @@
1
+ export * from "./lib/default-registry.js";
2
+ export * from "./lib/env-client-builder.js";
3
+ export * from "./lib/feed-generator.js";
4
+ export * from "./lib/feed-server.js";
5
+ export * from "./lib/feed-transformer.js";
6
+ export * from "./lib/feed-types.js";
7
+ export * from "./lib/sitemap-generator.js";
8
+ export * from "./lib/sitemap-xml.js";
9
+ export * from "./lib/load-cwd-env.js";
10
+ export * from "./lib/transformers/acp-product-feed.transformer.js";
11
+ export * from "./lib/transformers/google-merchant-feed.transformer.js";
12
+ export * from "./lib/transformers/pricerunner-feed.transformer.js";
13
+ export * from "./lib/transformers/sitemap-feed.transformer.js";
@@ -0,0 +1,18 @@
1
+ import {
2
+ ReactionaryFeedTransformerRegistry
3
+ } from "./feed-transformer.js";
4
+ import { acpProductFeedTransformer } from "./transformers/acp-product-feed.transformer.js";
5
+ import { googleMerchantFeedTransformer } from "./transformers/google-merchant-feed.transformer.js";
6
+ import { pricerunnerFeedTransformer } from "./transformers/pricerunner-feed.transformer.js";
7
+ import { sitemapFeedTransformer } from "./transformers/sitemap-feed.transformer.js";
8
+ function createDefaultFeedRegistry() {
9
+ const registry = new ReactionaryFeedTransformerRegistry();
10
+ registry.register(acpProductFeedTransformer);
11
+ registry.register(googleMerchantFeedTransformer);
12
+ registry.register(sitemapFeedTransformer);
13
+ registry.register(pricerunnerFeedTransformer);
14
+ return registry;
15
+ }
16
+ export {
17
+ createDefaultFeedRegistry
18
+ };