medusa-product-feed 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/README.md +122 -0
- package/dist/api/store/feed/route.d.ts +1 -0
- package/dist/api/store/feed/route.js +6 -0
- package/dist/feed-builder.d.ts +2 -0
- package/dist/feed-builder.js +101 -0
- package/dist/feed-cache.d.ts +9 -0
- package/dist/feed-cache.js +15 -0
- package/dist/feed-query.d.ts +8 -0
- package/dist/feed-query.js +58 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +21 -0
- package/dist/route.d.ts +3 -0
- package/dist/route.js +48 -0
- package/dist/subscriber.d.ts +3 -0
- package/dist/subscriber.js +22 -0
- package/dist/subscribers/product-feed-invalidation.d.ts +3 -0
- package/dist/subscribers/product-feed-invalidation.js +16 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.js +14 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# medusa-product-feed
|
|
2
|
+
|
|
3
|
+
A self-contained Medusa v2 plugin that generates a Google Merchant Center–compatible XML product feed at `GET /store/feed`. Ships its own API route and cache-invalidation subscriber — the consuming project needs zero extra files.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- Generates a Google Shopping RSS XML feed from your Medusa product catalog
|
|
8
|
+
- Caches the feed in Redis and auto-invalidates on `product.created`, `product.updated`, and `product.deleted` events
|
|
9
|
+
- Supports three variant rendering modes: `parent`, `children`, and `config` (runtime query param)
|
|
10
|
+
- Reads all configuration from environment variables
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install medusa-product-feed
|
|
16
|
+
# or with pnpm
|
|
17
|
+
pnpm add medusa-product-feed
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For local development with a symlinked package:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd /path/to/shared-medusa-utils/medusa-product-feed && npm run build
|
|
24
|
+
cd /path/to/your-medusa-backend && pnpm link /path/to/shared-medusa-utils/medusa-product-feed
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
Set environment variables in your `.env`:
|
|
30
|
+
|
|
31
|
+
| Variable | Default | Description |
|
|
32
|
+
|-----------------------|----------------------------|----------------------------------------------------------------|
|
|
33
|
+
| `FEED_STOREFRONT_URL` | `$STOREFRONT_URL` or `http://localhost:8000` | Base URL for product links in the feed |
|
|
34
|
+
| `FEED_CURRENCY` | `ILS` | Currency code for prices |
|
|
35
|
+
| `FEED_REGION_ID` | _(empty)_ | Medusa region ID to use for pricing |
|
|
36
|
+
| `FEED_VARIANT_MODE` | `children` | Variant rendering mode: `parent`, `children`, or `config` |
|
|
37
|
+
| `FEED_CACHE_TTL` | `3600` | Cache TTL in seconds |
|
|
38
|
+
| `FEED_DISABLE_CACHE` | `false` | Set to `true` to disable Redis caching (useful in development) |
|
|
39
|
+
| `FEED_STORE_NAME` | _(none)_ | Store name included in the feed `<title>` |
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
Register the plugin in your `medusa-config.js` (or `medusa-config.ts`):
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
module.exports = defineConfig({
|
|
47
|
+
plugins: [
|
|
48
|
+
{
|
|
49
|
+
resolve: "medusa-product-feed",
|
|
50
|
+
options: {},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
// ...
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That's it. No extra files needed in your project. The plugin automatically registers:
|
|
58
|
+
|
|
59
|
+
- `GET /store/feed` — the XML product feed endpoint
|
|
60
|
+
- A subscriber that invalidates the feed cache on product changes
|
|
61
|
+
|
|
62
|
+
## Feed URL
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
GET /store/feed
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Returns `application/xml` with a Google Merchant Center–compatible RSS feed.
|
|
69
|
+
|
|
70
|
+
Response headers:
|
|
71
|
+
- `X-Feed-Cache: HIT` — served from Redis cache
|
|
72
|
+
- `X-Feed-Cache: MISS` — freshly generated and cached
|
|
73
|
+
- `X-Feed-Cache: DISABLED` — caching is disabled
|
|
74
|
+
|
|
75
|
+
## Variant modes
|
|
76
|
+
|
|
77
|
+
| Mode | Behavior |
|
|
78
|
+
|------------|--------------------------------------------------------------------------|
|
|
79
|
+
| `parent` | One feed item per product (uses the first variant for price/SKU) |
|
|
80
|
+
| `children` | One feed item per variant (each variant is a separate `<item>`) |
|
|
81
|
+
| `config` | Resolved at request time via `?variants=parent` or `?variants=children` |
|
|
82
|
+
|
|
83
|
+
When `FEED_VARIANT_MODE=config`, callers control the mode per request:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
GET /store/feed?variants=children
|
|
87
|
+
GET /store/feed?variants=parent
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Cache behavior
|
|
91
|
+
|
|
92
|
+
The feed is cached in Redis using the Medusa cache module. Cache keys:
|
|
93
|
+
|
|
94
|
+
- `product-feed` — used for `parent` and `children` modes
|
|
95
|
+
- `product-feed:parent` / `product-feed:children` — used when mode is `config`
|
|
96
|
+
|
|
97
|
+
All keys are invalidated automatically when any product is created, updated, or deleted.
|
|
98
|
+
|
|
99
|
+
To disable caching (e.g. in development):
|
|
100
|
+
|
|
101
|
+
```env
|
|
102
|
+
FEED_DISABLE_CACHE=true
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Advanced: custom config per request
|
|
106
|
+
|
|
107
|
+
If you need custom configuration beyond what env vars provide (e.g. multi-tenant logic), you can use the `createFeedRouteHandler` factory directly in your own route file:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// src/api/store/feed/route.ts
|
|
111
|
+
import { createFeedRouteHandler } from "medusa-product-feed"
|
|
112
|
+
|
|
113
|
+
export const GET = createFeedRouteHandler({
|
|
114
|
+
storefrontUrl: "https://mystore.com",
|
|
115
|
+
currency: "USD",
|
|
116
|
+
regionId: "reg_01ABC",
|
|
117
|
+
variantMode: "children",
|
|
118
|
+
cacheTtlSeconds: 1800,
|
|
119
|
+
disableCache: false,
|
|
120
|
+
storeName: "My Store",
|
|
121
|
+
})
|
|
122
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const GET: (req: import("@medusajs/framework").MedusaRequest, res: import("@medusajs/framework").MedusaResponse) => Promise<void>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GET = void 0;
|
|
4
|
+
const route_1 = require("../../../route");
|
|
5
|
+
const types_1 = require("../../../types");
|
|
6
|
+
exports.GET = (0, route_1.createFeedRouteHandler)((0, types_1.feedConfigFromEnv)());
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildGoogleMerchantFeed = buildGoogleMerchantFeed;
|
|
4
|
+
function escapeXml(str) {
|
|
5
|
+
return str
|
|
6
|
+
.replace(/&/g, "&")
|
|
7
|
+
.replace(/</g, "<")
|
|
8
|
+
.replace(/>/g, ">")
|
|
9
|
+
.replace(/"/g, """)
|
|
10
|
+
.replace(/'/g, "'");
|
|
11
|
+
}
|
|
12
|
+
function stripHtml(html) {
|
|
13
|
+
return html.replace(/<[^>]*>/g, "").trim();
|
|
14
|
+
}
|
|
15
|
+
function formatPrice(amount, currency) {
|
|
16
|
+
return `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
|
17
|
+
}
|
|
18
|
+
function getPrice(variant, currency) {
|
|
19
|
+
const price = variant.prices.find((p) => p.currency_code.toLowerCase() === currency.toLowerCase());
|
|
20
|
+
return price ? formatPrice(price.amount, currency) : null;
|
|
21
|
+
}
|
|
22
|
+
function getAvailability(variant) {
|
|
23
|
+
if (!variant.manage_inventory)
|
|
24
|
+
return "in stock";
|
|
25
|
+
return variant.inventory_quantity > 0 ? "in stock" : "out of stock";
|
|
26
|
+
}
|
|
27
|
+
function getBrand(product, storeName) {
|
|
28
|
+
if (product.metadata?.brand)
|
|
29
|
+
return String(product.metadata.brand);
|
|
30
|
+
if (product.collection?.title)
|
|
31
|
+
return product.collection.title;
|
|
32
|
+
return storeName;
|
|
33
|
+
}
|
|
34
|
+
function buildItem(id, itemGroupId, product, variant, config) {
|
|
35
|
+
const title = itemGroupId
|
|
36
|
+
? `${product.title}${variant.title ? ` - ${variant.title}` : ""}`
|
|
37
|
+
: product.title;
|
|
38
|
+
const description = product.description ? stripHtml(product.description) : "";
|
|
39
|
+
const brand = getBrand(product, config.storeName ?? "");
|
|
40
|
+
const price = getPrice(variant, config.currency);
|
|
41
|
+
const availability = getAvailability(variant);
|
|
42
|
+
const link = `${config.storefrontUrl}/products/${product.handle}`;
|
|
43
|
+
const imageLink = product.images[0]?.url ?? "";
|
|
44
|
+
const additionalImages = product.images
|
|
45
|
+
.slice(1, 10)
|
|
46
|
+
.map((img) => ` <g:additional_image_link>${escapeXml(img.url)}</g:additional_image_link>`)
|
|
47
|
+
.join("\n");
|
|
48
|
+
const category = product.metadata?.google_category
|
|
49
|
+
? ` <g:google_product_category>${escapeXml(String(product.metadata.google_category))}</g:google_product_category>`
|
|
50
|
+
: "";
|
|
51
|
+
return [
|
|
52
|
+
" <item>",
|
|
53
|
+
` <g:id>${escapeXml(id)}</g:id>`,
|
|
54
|
+
itemGroupId ? ` <g:item_group_id>${escapeXml(itemGroupId)}</g:item_group_id>` : null,
|
|
55
|
+
` <title>${escapeXml(title)}</title>`,
|
|
56
|
+
` <description>${escapeXml(description)}</description>`,
|
|
57
|
+
` <link>${escapeXml(link)}</link>`,
|
|
58
|
+
imageLink ? ` <g:image_link>${escapeXml(imageLink)}</g:image_link>` : null,
|
|
59
|
+
additionalImages || null,
|
|
60
|
+
price ? ` <g:price>${escapeXml(price)}</g:price>` : null,
|
|
61
|
+
` <g:availability>${availability}</g:availability>`,
|
|
62
|
+
` <g:condition>${escapeXml(String(product.metadata?.condition ?? "new"))}</g:condition>`,
|
|
63
|
+
` <g:brand>${escapeXml(brand)}</g:brand>`,
|
|
64
|
+
category || null,
|
|
65
|
+
" </item>",
|
|
66
|
+
]
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join("\n");
|
|
69
|
+
}
|
|
70
|
+
function buildGoogleMerchantFeed(products, config) {
|
|
71
|
+
if (config.variantMode === "config") {
|
|
72
|
+
throw new Error('buildGoogleMerchantFeed: variantMode "config" must be resolved to "parent" or "children" before calling this function');
|
|
73
|
+
}
|
|
74
|
+
const items = [];
|
|
75
|
+
for (const product of products) {
|
|
76
|
+
if (config.variantMode === "parent") {
|
|
77
|
+
const firstVariant = product.variants[0];
|
|
78
|
+
if (!firstVariant)
|
|
79
|
+
continue;
|
|
80
|
+
items.push(buildItem(product.id, null, product, firstVariant, config));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
for (const variant of product.variants) {
|
|
84
|
+
items.push(buildItem(variant.id, product.id, product, variant, config));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const storeTitle = escapeXml(config.storeName ?? "Product Feed");
|
|
89
|
+
const storeUrl = escapeXml(config.storefrontUrl);
|
|
90
|
+
return [
|
|
91
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
92
|
+
'<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">',
|
|
93
|
+
"<channel>",
|
|
94
|
+
` <title>${storeTitle}</title>`,
|
|
95
|
+
` <link>${storeUrl}</link>`,
|
|
96
|
+
` <description>${storeTitle} product feed for Google Merchant Center</description>`,
|
|
97
|
+
...items,
|
|
98
|
+
"</channel>",
|
|
99
|
+
"</rss>",
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface CacheService {
|
|
2
|
+
get<T>(key: string): Promise<T | null>;
|
|
3
|
+
set(key: string, value: unknown, ttl?: number): Promise<void>;
|
|
4
|
+
invalidate(key: string): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare function getFeed(cache: CacheService, key?: string): Promise<string | null>;
|
|
7
|
+
export declare function setFeed(cache: CacheService, xml: string, ttlSeconds: number, key?: string): Promise<void>;
|
|
8
|
+
export declare function invalidateFeed(cache: CacheService, key?: string): Promise<void>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getFeed = getFeed;
|
|
4
|
+
exports.setFeed = setFeed;
|
|
5
|
+
exports.invalidateFeed = invalidateFeed;
|
|
6
|
+
const DEFAULT_CACHE_KEY = "product-feed";
|
|
7
|
+
async function getFeed(cache, key = DEFAULT_CACHE_KEY) {
|
|
8
|
+
return cache.get(key);
|
|
9
|
+
}
|
|
10
|
+
async function setFeed(cache, xml, ttlSeconds, key = DEFAULT_CACHE_KEY) {
|
|
11
|
+
await cache.set(key, xml, ttlSeconds);
|
|
12
|
+
}
|
|
13
|
+
async function invalidateFeed(cache, key = DEFAULT_CACHE_KEY) {
|
|
14
|
+
await cache.invalidate(key);
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FeedProduct } from "./types";
|
|
2
|
+
interface RemoteQuery {
|
|
3
|
+
graph(options: object): Promise<{
|
|
4
|
+
data: any[];
|
|
5
|
+
}>;
|
|
6
|
+
}
|
|
7
|
+
export declare function fetchFeedProducts(query: RemoteQuery, currency: string, regionId: string): Promise<FeedProduct[]>;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchFeedProducts = fetchFeedProducts;
|
|
4
|
+
const utils_1 = require("@medusajs/framework/utils");
|
|
5
|
+
async function fetchFeedProducts(query, currency, regionId) {
|
|
6
|
+
if (!regionId) {
|
|
7
|
+
console.warn("[medusa-product-feed] FEED_REGION_ID is not set — prices may not resolve correctly");
|
|
8
|
+
}
|
|
9
|
+
const pricingContext = { currency_code: currency.toLowerCase() };
|
|
10
|
+
if (regionId)
|
|
11
|
+
pricingContext.region_id = regionId;
|
|
12
|
+
const { data: products } = await query.graph({
|
|
13
|
+
entity: "product",
|
|
14
|
+
fields: [
|
|
15
|
+
"id",
|
|
16
|
+
"title",
|
|
17
|
+
"handle",
|
|
18
|
+
"description",
|
|
19
|
+
"status",
|
|
20
|
+
"metadata",
|
|
21
|
+
"images.url",
|
|
22
|
+
"collection.title",
|
|
23
|
+
"variants.id",
|
|
24
|
+
"variants.title",
|
|
25
|
+
"variants.sku",
|
|
26
|
+
"variants.manage_inventory",
|
|
27
|
+
"variants.inventory_quantity",
|
|
28
|
+
"variants.calculated_price.calculated_amount",
|
|
29
|
+
"variants.calculated_price.currency_code",
|
|
30
|
+
],
|
|
31
|
+
filters: { status: "published" },
|
|
32
|
+
context: {
|
|
33
|
+
variants: {
|
|
34
|
+
calculated_price: (0, utils_1.QueryContext)(pricingContext),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return products.map((p) => ({
|
|
39
|
+
id: p.id,
|
|
40
|
+
title: p.title ?? "",
|
|
41
|
+
handle: p.handle ?? "",
|
|
42
|
+
description: p.description ?? null,
|
|
43
|
+
images: (p.images ?? []).map((img) => ({ url: img.url })),
|
|
44
|
+
collection: p.collection ? { title: p.collection.title } : null,
|
|
45
|
+
variants: (p.variants ?? []).map((v) => ({
|
|
46
|
+
id: v.id,
|
|
47
|
+
title: v.title ?? null,
|
|
48
|
+
sku: v.sku ?? null,
|
|
49
|
+
// calculated_amount is in display units (e.g. 99.00), multiply by 100 to match FeedVariant.prices minor-unit convention
|
|
50
|
+
prices: v.calculated_price?.calculated_amount != null
|
|
51
|
+
? [{ currency_code: currency.toLowerCase(), amount: Math.round(v.calculated_price.calculated_amount * 100) }]
|
|
52
|
+
: [],
|
|
53
|
+
manage_inventory: v.manage_inventory ?? false,
|
|
54
|
+
inventory_quantity: v.inventory_quantity ?? 0,
|
|
55
|
+
})),
|
|
56
|
+
metadata: p.metadata ?? null,
|
|
57
|
+
}));
|
|
58
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { FeedConfig, FeedProduct, FeedVariant, VariantMode } from "./types";
|
|
2
|
+
export { feedConfigFromEnv } from "./types";
|
|
3
|
+
export { buildGoogleMerchantFeed } from "./feed-builder";
|
|
4
|
+
export { getFeed, setFeed, invalidateFeed } from "./feed-cache";
|
|
5
|
+
export { fetchFeedProducts } from "./feed-query";
|
|
6
|
+
export { createFeedRouteHandler } from "./route";
|
|
7
|
+
export { default as productFeedInvalidationSubscriber, config as subscriberConfig } from "./subscribers/product-feed-invalidation";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.subscriberConfig = exports.productFeedInvalidationSubscriber = exports.createFeedRouteHandler = exports.fetchFeedProducts = exports.invalidateFeed = exports.setFeed = exports.getFeed = exports.buildGoogleMerchantFeed = exports.feedConfigFromEnv = void 0;
|
|
7
|
+
var types_1 = require("./types");
|
|
8
|
+
Object.defineProperty(exports, "feedConfigFromEnv", { enumerable: true, get: function () { return types_1.feedConfigFromEnv; } });
|
|
9
|
+
var feed_builder_1 = require("./feed-builder");
|
|
10
|
+
Object.defineProperty(exports, "buildGoogleMerchantFeed", { enumerable: true, get: function () { return feed_builder_1.buildGoogleMerchantFeed; } });
|
|
11
|
+
var feed_cache_1 = require("./feed-cache");
|
|
12
|
+
Object.defineProperty(exports, "getFeed", { enumerable: true, get: function () { return feed_cache_1.getFeed; } });
|
|
13
|
+
Object.defineProperty(exports, "setFeed", { enumerable: true, get: function () { return feed_cache_1.setFeed; } });
|
|
14
|
+
Object.defineProperty(exports, "invalidateFeed", { enumerable: true, get: function () { return feed_cache_1.invalidateFeed; } });
|
|
15
|
+
var feed_query_1 = require("./feed-query");
|
|
16
|
+
Object.defineProperty(exports, "fetchFeedProducts", { enumerable: true, get: function () { return feed_query_1.fetchFeedProducts; } });
|
|
17
|
+
var route_1 = require("./route");
|
|
18
|
+
Object.defineProperty(exports, "createFeedRouteHandler", { enumerable: true, get: function () { return route_1.createFeedRouteHandler; } });
|
|
19
|
+
var product_feed_invalidation_1 = require("./subscribers/product-feed-invalidation");
|
|
20
|
+
Object.defineProperty(exports, "productFeedInvalidationSubscriber", { enumerable: true, get: function () { return __importDefault(product_feed_invalidation_1).default; } });
|
|
21
|
+
Object.defineProperty(exports, "subscriberConfig", { enumerable: true, get: function () { return product_feed_invalidation_1.config; } });
|
package/dist/route.d.ts
ADDED
package/dist/route.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createFeedRouteHandler = createFeedRouteHandler;
|
|
4
|
+
const utils_1 = require("@medusajs/framework/utils");
|
|
5
|
+
const feed_cache_1 = require("./feed-cache");
|
|
6
|
+
const feed_query_1 = require("./feed-query");
|
|
7
|
+
const feed_builder_1 = require("./feed-builder");
|
|
8
|
+
function createFeedRouteHandler(config) {
|
|
9
|
+
return async function GET(req, res) {
|
|
10
|
+
try {
|
|
11
|
+
const cacheService = req.scope.resolve(utils_1.Modules.CACHE);
|
|
12
|
+
const query = req.scope.resolve(utils_1.ContainerRegistrationKeys.QUERY);
|
|
13
|
+
let resolvedVariantMode = config.variantMode;
|
|
14
|
+
if (config.variantMode === "config") {
|
|
15
|
+
const qv = req.query["variants"];
|
|
16
|
+
if (typeof qv === "string" && (qv === "parent" || qv === "children")) {
|
|
17
|
+
resolvedVariantMode = qv;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
resolvedVariantMode = "children";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const cacheKey = `product-feed:${resolvedVariantMode}`;
|
|
24
|
+
const effectiveConfig = { ...config, variantMode: resolvedVariantMode };
|
|
25
|
+
if (!config.disableCache) {
|
|
26
|
+
const cached = await (0, feed_cache_1.getFeed)(cacheService, cacheKey);
|
|
27
|
+
if (cached) {
|
|
28
|
+
res.setHeader("Content-Type", "application/xml; charset=utf-8");
|
|
29
|
+
res.setHeader("X-Feed-Cache", "HIT");
|
|
30
|
+
res.send(cached);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const products = await (0, feed_query_1.fetchFeedProducts)(query, config.currency, config.regionId);
|
|
35
|
+
const xml = (0, feed_builder_1.buildGoogleMerchantFeed)(products, effectiveConfig);
|
|
36
|
+
if (!config.disableCache) {
|
|
37
|
+
await (0, feed_cache_1.setFeed)(cacheService, xml, config.cacheTtlSeconds, cacheKey);
|
|
38
|
+
}
|
|
39
|
+
res.setHeader("Content-Type", "application/xml; charset=utf-8");
|
|
40
|
+
res.setHeader("X-Feed-Cache", config.disableCache ? "DISABLED" : "MISS");
|
|
41
|
+
res.send(xml);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
console.error("[medusa-product-feed] error:", err);
|
|
45
|
+
res.status(500).send("Feed generation failed");
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.config = void 0;
|
|
4
|
+
exports.productFeedInvalidationSubscriber = productFeedInvalidationSubscriber;
|
|
5
|
+
const utils_1 = require("@medusajs/framework/utils");
|
|
6
|
+
const feed_cache_1 = require("./feed-cache");
|
|
7
|
+
async function productFeedInvalidationSubscriber({ container, }) {
|
|
8
|
+
const cacheService = container.resolve(utils_1.Modules.CACHE);
|
|
9
|
+
// Invalidate all possible cache keys (fixed key + per-mode keys)
|
|
10
|
+
await Promise.all([
|
|
11
|
+
(0, feed_cache_1.invalidateFeed)(cacheService),
|
|
12
|
+
(0, feed_cache_1.invalidateFeed)(cacheService, "product-feed:parent"),
|
|
13
|
+
(0, feed_cache_1.invalidateFeed)(cacheService, "product-feed:children"),
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
exports.config = {
|
|
17
|
+
event: [
|
|
18
|
+
"product.created",
|
|
19
|
+
"product.updated",
|
|
20
|
+
"product.deleted",
|
|
21
|
+
],
|
|
22
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.config = void 0;
|
|
4
|
+
exports.default = productFeedInvalidationSubscriber;
|
|
5
|
+
const utils_1 = require("@medusajs/framework/utils");
|
|
6
|
+
const feed_cache_1 = require("../feed-cache");
|
|
7
|
+
async function productFeedInvalidationSubscriber({ container, }) {
|
|
8
|
+
const cacheService = container.resolve(utils_1.Modules.CACHE);
|
|
9
|
+
await Promise.all([
|
|
10
|
+
(0, feed_cache_1.invalidateFeed)(cacheService, "product-feed:parent"),
|
|
11
|
+
(0, feed_cache_1.invalidateFeed)(cacheService, "product-feed:children"),
|
|
12
|
+
]);
|
|
13
|
+
}
|
|
14
|
+
exports.config = {
|
|
15
|
+
event: ["product.created", "product.updated", "product.deleted"],
|
|
16
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type VariantMode = "parent" | "children" | "config";
|
|
2
|
+
export interface FeedConfig {
|
|
3
|
+
storefrontUrl: string;
|
|
4
|
+
currency: string;
|
|
5
|
+
regionId: string;
|
|
6
|
+
variantMode: VariantMode;
|
|
7
|
+
cacheTtlSeconds: number;
|
|
8
|
+
disableCache?: boolean;
|
|
9
|
+
storeName?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface FeedVariant {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string | null;
|
|
14
|
+
sku: string | null;
|
|
15
|
+
prices: Array<{
|
|
16
|
+
currency_code: string;
|
|
17
|
+
amount: number;
|
|
18
|
+
}>;
|
|
19
|
+
manage_inventory: boolean;
|
|
20
|
+
inventory_quantity: number;
|
|
21
|
+
}
|
|
22
|
+
export interface FeedProduct {
|
|
23
|
+
id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
handle: string;
|
|
26
|
+
description: string | null;
|
|
27
|
+
images: Array<{
|
|
28
|
+
url: string;
|
|
29
|
+
}>;
|
|
30
|
+
collection: {
|
|
31
|
+
title: string;
|
|
32
|
+
} | null;
|
|
33
|
+
variants: FeedVariant[];
|
|
34
|
+
metadata: Record<string, unknown> | null;
|
|
35
|
+
}
|
|
36
|
+
export declare function feedConfigFromEnv(): FeedConfig;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.feedConfigFromEnv = feedConfigFromEnv;
|
|
4
|
+
function feedConfigFromEnv() {
|
|
5
|
+
return {
|
|
6
|
+
storefrontUrl: process.env.FEED_STOREFRONT_URL ?? process.env.STOREFRONT_URL ?? "http://localhost:8000",
|
|
7
|
+
currency: process.env.FEED_CURRENCY ?? "ILS",
|
|
8
|
+
regionId: process.env.FEED_REGION_ID ?? "",
|
|
9
|
+
variantMode: (process.env.FEED_VARIANT_MODE ?? "children"),
|
|
10
|
+
cacheTtlSeconds: Number(process.env.FEED_CACHE_TTL ?? 3600),
|
|
11
|
+
disableCache: process.env.FEED_DISABLE_CACHE === "true",
|
|
12
|
+
storeName: process.env.FEED_STORE_NAME,
|
|
13
|
+
};
|
|
14
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "medusa-product-feed",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Google Merchant Center XML product feed plugin for Medusa 2.x",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": ["dist"],
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"test": "jest",
|
|
17
|
+
"test:unit": "jest src"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@swc/core": "^1.5.7",
|
|
21
|
+
"@swc/jest": "^0.2.39",
|
|
22
|
+
"@types/jest": "^29.5.14",
|
|
23
|
+
"@types/node": "^20.19.25",
|
|
24
|
+
"jest": "^29.7.0",
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@medusajs/framework": "^2.13.6",
|
|
29
|
+
"@medusajs/medusa": "^2.13.6"
|
|
30
|
+
}
|
|
31
|
+
}
|