We faced the same issue in a Mini we’re developing. The solution we came up with is roughly what @Quique-Shopify suggested:
I thought I’d share the details, in case anyone else faces this problem.
In our mini, we created a component <ProductSearchProvider /> that the main <App /> has as a child. The job of ProductSearchProvider is to listen for search requests from our server, and perform the searches for the given client, passing back the results. It listens for events by opening an SSE channel to the server, using credentials as with the reference implementation on the custom backend ( shop-minis/supabase/README.md at main · Shopify/shop-minis · GitHub )
On the server, the channel is associated with a request queue for the given user. When the server needs to search for products for the user, it posts a request object onto that queue, which then gets sent via the SSE channel to the client.
When the request provider gets the request, it uses product search (via useProductSearch) to perform the result, posting the response to a route of the form /api/product-search/.
When the server gets the response, it resolves a promise with the value, making it available for whatever is waiting for it.
In code:
On the server, it looks roughly like this:
type Search = { numProducts: number, query: string, ...etc. }
// The queues representing SSE streams. A Queue<T> has the following methods:
// post(t: T):void - throws if there are no listeners
// on(type: "event", listener: (t:T) => void):void
// off(type: "event", listener: (t:T) => void):void)
// listenerCount(type:"event"):number
const sseQueues = new Map<UserId, Queue<Search & { reqId: string }>>();
// A map representing unresolved search requests
const awaitingReply = new Map<ReqId, { resolve: (_:SearchResponse)=>void, reject: (reason:string) => void }>()
// The route that creates the SSE channels to listen to
app.get("/api/search", async (req, res) => {
const userId = await auth(req); // a helper that checks the user is authenticated
const sseQueue = sseQueues.get(userId) ?? new Queue();
sseQueues.set(userId, sseQueue); // ensure that the queue is there
// An sse channel has methods post, and on("close").
// You can look here, as to how to build one:https://stackoverflow.com/questions/34657222/how-to-use-server-sent-events-in-express-js
const sseChannel = createSSEChannel(res);
const listen = (e:Search)=>{sseChannel.post(e)}
queue.on("event", listen); // post to all channels -- that way at least one Mini will response
sseChannel.on("close", () => {
queue.off("event", listen);
// ensure that the queue is removed when there are no more Minis listening to it
if (queue.listenerCount("event") === 0) { sseQueues.delete(userId); }
}
})
// The route that listens for responses
app.post("/api/search/:id", async (req, res) => {
const resolvers = awaitingReply.get(req.params.id);
if (!resolvers) {
// already resolved - first past the post wins.
res.sendStatus(200);
return;
}
const { resolve, reject } = resolvers;
try {
const result = parseSearchResult(res.body);
if (result.status === "ok") { resolve(result.payload); }
else { reject(new Error(result.reason)) }
awaitingReply.delete(req.params.id);
res.sendStatus(200);
return;
} catch (e) {
console.warn("Error parsing search result");
console.debug(e);
res.sendStatus(400);
}
});
/**
* Performs a search for the given user.
*/
async function productSearch(userId: UserId, search: Search, signal?: AbortSignal) {
const {promise, resolve, reject} = Promise.withResolvers<SearchResult>();
const queue = sseQueue.get(userId);
const requestId = crypto.randomUUID();
awaitingReply.set(requestingId, { resolve, reject })
if (!queue) { reject(new Error("No listeners")); awaitingReply.delete(requestId); }
queue.post(search).catch(e => {
console.warn(e);
reject("Error posting search");
awaitingReply.delete(requestId);
})
return promise;
}
In the mini, ProductSearchProvider looks like this:
function ProductSearchProvider() {
const [openRequests, setOpenRequests] = useState<Search & {reqId: string}>([])
useEffect(() => {
const channel = openSSEChannel(); // a small wrapper over SSE -Using server-sent events - Web APIs | MDN
channel.on("event", (e) => {
const req = parseEvent(e);
setOpenRequests(requests => requests.some(r => r.reqId === req.reqId) ? requests : [...requests, req]);
return () => { channel.close() }
}, [setOpenRequests]);
const onResponse = useCallback(async (queryId: string, res:SearchResult) => {
if (!openRequests.some(r => r.id === queryId)) { return; }
const res = await fetch(<API_ROUTE/queryId>, { method: "POST", body: JSON.stringify(res), ... });
if (!res.ok) {
console.warn("Bad response");
}
setOpenRequests(rs => rs.filter(r => r.id !== queryId))
}, [setOpenRequests, openRequests])
// The key is important -- it makes sure the ProductSearch stays associated with a particular request
return <>{openRequests.map(r => <ProductSearch search={r} onResponse={onResponse} key={r.reqId} />)}</>;
}
function ProductSearch({ search: { reqId, numProducts, ...searchParams }, onResponse }: { queryId: string, search: Search & { reqId: string }, onResponse: (queryId: string, res:SearchResult) => void }) {
const { error, fetchMore, hasNextPage, loading, products } = useSearchResults();
useEffect(() => {
if (loading) { return; }
if (error) {
console.log("Error in search:", error);
onResponse(reqId, { status: "error", reason: error });
return;
}
if (hasNextPage && (products ?? []).length < numProducts)) {
fetchMore();
return;
}
onResponse(reqId, { status: "ok", payload: { products } });
}, [loading, hasNextPage, fetchMore, error, products, onResponse]);
return null;
}
That is a basic sketch. I hope it helps anyone coming across this later.
Excuse any typos or errors – I’ve written this from memory and too little sleep. For production, there are a bunch of edge cases to take care of (concurrency across different machines etc), but this should show the basic idea.