import { generateDbImport, isPostgresCartDataSource } from './ecommerce-api-routes-generator'
/**
* Product media is stored on the row in ONE of two shapes:
*
* - a direct URL — the merchant picked a stock photo (Unsplash / Pexels) or
* typed one in;
* - the id of a PROJECT ASSET — the merchant picked an image that already
* lived in the project's asset library, and only its id is written to
* `teleport_products.image_url` / `gallery_images` (and to the per-variant
* override on `teleport_product_variants.image_url`).
*
* The editor resolves the second shape against the live project document, and
* the generated DATA-SOURCE fetchers resolve it server-side against the
* `teleport_assets` mirror table. Every OTHER reader used to render the bare id
* straight into an `
`, where the browser resolves it against the site
* origin and asks for `/` — a 404 and a blank product photo. That is what
* emptied the cart and checkout thumbnails.
*
* This module is the one place that turns an id into a URL for those readers:
* the storefront bundle (the cart provider) calls the client half, which goes
* through `/api/ecommerce/assets`; server-side readers (that route, and the
* order-notification email's line loader) call the server half with their own
* database handle. Writing the id → URL contract once is what keeps a product's
* thumbnail the same file on the listing page, in the cart, on the checkout
* summary and in the merchant's inbox.
*/
// Ids per lookup request. Shared by both halves so the client can never send a
// batch the route rejects.
const MAX_IDS_PER_REQUEST = 200
// How long a FAILED lookup suppresses further attempts. A store whose
// `teleport_assets` mirror was never provisioned would otherwise fire one
// doomed request per cart change, for the life of the page.
const LOOKUP_COOLDOWN_MS = 30000
const ASSETS_ENDPOINT = '/api/ecommerce/assets'
/** `utils/ecommerce/asset-urls.js` — see the module doc comment above. */
export const generateAssetUrlsModule = (): string => `/**
* Resolves stored product media to a URL a browser can load.
*
* A media value on a product / variant row is EITHER a direct URL (a stock
* photo the merchant picked) OR the id of a project asset (an image that
* already lived in the project). Only the generated data-source fetchers
* resolved the second form — against the \`teleport_assets\` mirror table — so
* every other reader rendered the bare id into an \`
\`, the browser
* asked this site for \`/\`, and the product photo came back 404.
*
* Shared by the storefront bundle (the cart provider imports the client half,
* which goes through \`/api/ecommerce/assets\`) and by the server-side readers
* that hand it their own database handle — that route, and the
* order-notification email's line loader. The id → URL contract is written
* down once.
*
* Deliberately dependency-free: it is \`require\`d by an API route AND bundled
* into the browser, so nothing here may reach for \`pg\` or for the DOM at
* module scope.
*/
// The batch endpoint the client half calls. Emitted by the same project plugin
// that emits this module, so the two can never disagree.
var ASSETS_ENDPOINT = '${ASSETS_ENDPOINT}'
// Ids per request. The route rejects a larger batch, so the client half splits
// a bigger set across several calls rather than silently dropping ids.
var MAX_IDS_PER_REQUEST = ${MAX_IDS_PER_REQUEST}
// How long a failed lookup suppresses further attempts — see the module doc.
var LOOKUP_COOLDOWN_MS = ${LOOKUP_COOLDOWN_MS}
function hasOwn(object, key) {
return !!object && Object.prototype.hasOwnProperty.call(object, key)
}
// Whether the value is already something an \`
\` can load: an absolute
// URL, a data/blob URL, a protocol-relative \`//cdn/...\` or a root-relative
// \`/uploads/x.png\`. A bare asset id is none of those.
//
// The scheme is matched case-INSENSITIVELY because a browser treats it that
// way: a hand-typed \`HTTPS://…\` loads today, and reading it as an asset id
// would look the value up, find nothing, and blank a picture that worked.
function isDirectAssetUrl(value) {
if (!value || typeof value !== 'string') {
return false
}
if (value.charAt(0) === '/') {
return true
}
var scheme = value.slice(0, 8).toLowerCase()
return (
scheme.indexOf('http://') === 0 ||
scheme.indexOf('https://') === 0 ||
scheme.indexOf('data:') === 0 ||
scheme.indexOf('blob:') === 0
)
}
function needsAssetLookup(value) {
return typeof value === 'string' && value.length > 0 && !isDirectAssetUrl(value)
}
// The distinct asset ids inside a mixed list of media values, in first-seen
// order. A store built entirely from stock photography yields an EMPTY list,
// which is what makes the lookup cost nothing for those stores.
function collectAssetLookupIds(values) {
var ids = []
var seen = {}
var list = values || []
for (var i = 0; i < list.length; i++) {
var value = list[i]
if (!needsAssetLookup(value) || seen[value]) {
continue
}
seen[value] = true
ids.push(value)
}
return ids
}
// Column precedence of \`teleport_assets\`, identical to \`resolveAssetUrl\` in
// the generated data-source fetchers (\`remoteSrc || imageSrc || src || url\`)
// so one asset resolves to the same file on the product listing and in the
// cart.
function assetUrlFromRow(row) {
if (!row) {
return null
}
return row.remote_src || row.image_src || row.src || row.url || null
}
// \`{ id: url | null }\` covering EVERY requested id. An id with no row is
// recorded as \`null\` rather than left out: "this asset is gone" and "the
// lookup never ran" must stay distinguishable, and callers tell them apart by
// whether the key is present at all.
function buildAssetUrlMap(ids, rows) {
var byId = {}
var list = rows || []
for (var r = 0; r < list.length; r++) {
var row = list[r]
if (row && row.id != null) {
byId[String(row.id)] = assetUrlFromRow(row)
}
}
var map = {}
var requested = ids || []
for (var i = 0; i < requested.length; i++) {
map[requested[i]] = hasOwn(byId, requested[i]) ? byId[requested[i]] || null : null
}
return map
}
// ── Server half ───────────────────────────────────────────────────────────────
// The mirror table the editor keeps in step with the project's asset library.
var ASSETS_QUERY =
'SELECT id, src, remote_src, image_src, url FROM teleport_assets WHERE id = ANY($1)'
// Reads the mirror for whatever inside \`values\` needs resolving. Takes the
// database handle rather than opening one, so this module stays free of \`pg\`
// and can be bundled into the browser alongside the client half.
//
// THROWS when the query fails — the mirror is provisioned by the editor and can
// legitimately be absent. That leaves the decision with the caller: an endpoint
// whose whole job is to answer must report that it could not, while a caller
// for whom the image is a nicety (an email) catches and carries on without one.
async function loadAssetUrlMapFromDb(db, values) {
var ids = collectAssetLookupIds(values)
if (ids.length === 0) {
return {}
}
var result = await db.query(ASSETS_QUERY, [ids])
return buildAssetUrlMap(ids, (result && result.rows) || [])
}
// ── Rendering ─────────────────────────────────────────────────────────────────
// The URL to RENDER for a stored media value, or \`null\` when there is none.
//
// A bare asset id must never reach an \`
\`: the browser resolves it
// against the site origin, asks for \`/\` and paints a broken image where
// the product photo belongs. So anything that did not become a loadable URL is
// reported as absent.
//
// The three cases the map distinguishes, and why each answer is the right one:
// - the map ANSWERED with a URL → that URL;
// - the map ANSWERED with \`null\` → the asset was deleted, and its CDN
// URL died with it, so the thumbnail must CLEAR rather than fall back to a
// copy that is equally dead;
// - the map has NOTHING to say → the lookup never ran or could not
// answer, so \`fallback\` (the URL an earlier pass already resolved for this
// line) keeps the thumbnail on screen through the outage.
function resolveMediaUrl(value, assetUrlMap, fallback) {
if (!needsAssetLookup(value)) {
return isDirectAssetUrl(value) ? value : null
}
if (hasOwn(assetUrlMap, value)) {
return isDirectAssetUrl(assetUrlMap[value]) ? assetUrlMap[value] : null
}
return isDirectAssetUrl(fallback) ? fallback : null
}
// ── Client half ───────────────────────────────────────────────────────────────
// Answers already paid for, for the life of the page. Entries are
// authoritative (\`null\` = the asset is gone), so a cached id is never asked
// for twice.
var assetUrlCache = {}
// In-flight requests keyed by their id set, so the provider's mount pass and
// the \`teleport:cart-changed\` pass that immediately follows it share ONE round
// trip instead of racing two identical ones.
var pendingLookups = {}
var lookupCooldownUntil = 0
function requestAssetUrls(ids) {
var key = ids.slice().sort().join(',')
if (pendingLookups[key]) {
return pendingLookups[key]
}
var request = fetch(ASSETS_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ids }),
})
.then(function (response) {
if (!response.ok) {
throw new Error('asset lookup failed with status ' + response.status)
}
return response.json()
})
.then(function (body) {
var assets = (body && body.assets) || {}
for (var i = 0; i < ids.length; i++) {
assetUrlCache[ids[i]] = hasOwn(assets, ids[i]) ? assets[ids[i]] || null : null
}
delete pendingLookups[key]
return assets
})
.catch(function () {
// Leave the ids UNCACHED — a later pass must still be able to resolve
// them — but hold off for a while so a store without the mirror table
// does not fire one doomed request per cart change.
lookupCooldownUntil = Date.now() + LOOKUP_COOLDOWN_MS
delete pendingLookups[key]
return null
})
pendingLookups[key] = request
return request
}
// Resolves whatever in \`values\` needs resolving and answers with an
// id → URL map. NEVER rejects: a failed lookup answers with what is already
// known, and \`resolveMediaUrl\`'s fallback keeps the previous image on screen.
async function loadAssetUrlMap(values) {
var map = {}
var ids = collectAssetLookupIds(values)
if (ids.length === 0) {
return map
}
var missing = []
for (var i = 0; i < ids.length; i++) {
if (hasOwn(assetUrlCache, ids[i])) {
map[ids[i]] = assetUrlCache[ids[i]]
} else {
missing.push(ids[i])
}
}
if (missing.length === 0) {
return map
}
// Never on the server. A relative endpoint has no origin to resolve against
// there, and Node's global \`fetch\` would reject on it — arming the cooldown
// in a module scope that outlives the request and starving the browsers this
// process then serves. The cart is a client-side surface; there is nothing to
// paint during SSR anyway. The cooldown itself covers a lookup that is down.
if (typeof window === 'undefined' || typeof fetch !== 'function') {
return map
}
if (Date.now() < lookupCooldownUntil) {
return map
}
for (var start = 0; start < missing.length; start += MAX_IDS_PER_REQUEST) {
var batch = missing.slice(start, start + MAX_IDS_PER_REQUEST)
var assets = await requestAssetUrls(batch)
if (!assets) {
// The lookup is down: stop asking for the rest of this pass.
break
}
for (var b = 0; b < batch.length; b++) {
map[batch[b]] = hasOwn(assets, batch[b]) ? assets[batch[b]] || null : null
}
}
return map
}
module.exports = {
isDirectAssetUrl: isDirectAssetUrl,
collectAssetLookupIds: collectAssetLookupIds,
loadAssetUrlMapFromDb: loadAssetUrlMapFromDb,
resolveMediaUrl: resolveMediaUrl,
loadAssetUrlMap: loadAssetUrlMap,
}
`
/**
* `pages/api/ecommerce/assets.js` — the batch id → URL resolver the storefront
* bundle calls.
*
* Returns `null` when the datasource cannot answer it (no Postgres client, so
* no `teleport_assets` mirror to read): the project plugin then emits neither
* this route NOR the resolution calls that would hit it, and stored media
* values are used exactly as they were before.
*
* Public on purpose, like `/api/ecommerce/variants` beside it: it answers only
* for ids the caller already knows, and every URL it hands back is the same
* public CDN URL the product listing already renders.
*/
export const generateAssetsApiRoute = (
dataSourceType: string | null,
dataSourceConfig: Record | null
): string | null => {
const dbImport = generateDbImport(dataSourceType, dataSourceConfig)
if (!dbImport || !isPostgresCartDataSource(dataSourceType)) {
return null
}
return `${dbImport}
var assetUrls = require('../../../utils/ecommerce/asset-urls')
// Batch resolver for the project-asset ids stored on product and variant rows.
// The mapping (which \`teleport_assets\` column wins, and what an unknown id
// means) lives in \`utils/ecommerce/asset-urls\` so the browser half and this
// route can never drift apart.
var MAX_IDS = ${MAX_IDS_PER_REQUEST}
// GET keeps the endpoint reachable by hand (\`?ids=a,b\`); POST is what the
// storefront uses, because a cart's worth of uuids does not belong in a URL.
function readRequestedIds(req) {
if (req.method === 'POST') {
var body = req.body && typeof req.body === 'object' ? req.body : {}
return Array.isArray(body.ids) ? body.ids : []
}
var raw = (req.query && req.query.ids) || ''
return String(Array.isArray(raw) ? raw.join(',') : raw).split(',')
}
export default async function handler(req, res) {
if (req.method !== 'POST' && req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' })
}
var requested = readRequestedIds(req).map(function (value) {
return typeof value === 'string' ? value.trim() : ''
})
// Drops blanks, duplicates and anything that is already a URL — a caller
// that passes a whole column through has nothing to special-case.
var ids = assetUrls.collectAssetLookupIds(requested)
if (ids.length === 0) {
return res.status(200).json({ assets: {} })
}
if (ids.length > MAX_IDS) {
return res.status(400).json({ error: 'Too many ids requested' })
}
try {
// A missing id comes back as \`null\`, which the caller reads as "this asset
// is gone" — distinct from the failure below, which it reads as "ask again
// later" and answers with the image it already had.
return res.status(200).json({ assets: await assetUrls.loadAssetUrlMapFromDb(db, ids) })
} catch (error) {
// The mirror table is provisioned by the editor and can legitimately be
// absent on a store that never had a project asset on a product. Failing
// loudly is right: the caller must not mistake "cannot answer" for "no such
// asset" and blank a thumbnail it already had.
console.error('Asset URL lookup error:', error)
return res.status(500).json({ error: 'Internal server error' })
}
}
`
}