@umxr/bagsy 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Umar Gora
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,155 @@
1
- # Temporary Holding Version
1
+ # bagsy
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Wishlists for Shopify, built for developers. It covers Liquid themes and Hydrogen storefronts with one API, a typed client and no remotely loaded code.
4
+
5
+ ```sh
6
+ npm install @umxr/bagsy
7
+ ```
8
+
9
+ You also need the Bagsy app installed on the store. Server keys are in **Apps → Bagsy → Developers**.
10
+
11
+ | Import | For | Peer dependency |
12
+ | --- | --- | --- |
13
+ | `@umxr/bagsy` | The typed client: lists, items, saved lookups, sharing, insights | none |
14
+ | `@umxr/bagsy/react` | `useSaved`, `useWishlist`, `useWishlistCount`, `<WishlistButton>` | `react` 18 or 19 |
15
+ | `@umxr/bagsy/hydrogen` | Hydrogen 2026-10 route handlers, shopper identity, server-rendered state | `@shopify/hydrogen` ≥ 2026.10 |
16
+ | `@umxr/bagsy/server` | The same as `@umxr/bagsy/hydrogen`, for any server framework | none |
17
+ | `@umxr/bagsy/element` | `<bagsy-button>`, `<bagsy-count>`, `<bagsy-wishlist>` web components | none |
18
+ | `@umxr/bagsy/styles.css` | Default styles for the web components | none |
19
+
20
+ Everything is ESM, typed and tree-shakeable.
21
+
22
+ ## Hydrogen
23
+
24
+ Your server relays the browser's calls to Bagsy, adding your key and the shopper's identity from the session. The browser never holds a key, a customer id or a guest id.
25
+
26
+ ```ts
27
+ // app/lib/bagsy.server.ts
28
+ import { createHydrogenBagsy } from '@umxr/bagsy/hydrogen';
29
+
30
+ export const bagsy = createHydrogenBagsy({
31
+ apiKey: process.env.BAGSY_API_KEY!,
32
+ baseUrl: process.env.BAGSY_API_URL, // https://bagsy-api.<your-subdomain>.workers.dev/v1
33
+ customerSession, // from createCustomerSession(); omit both for guest-only
34
+ shopId: process.env.SHOP_ID!,
35
+ });
36
+ ```
37
+
38
+ ```ts
39
+ // wherever you call handleShopifyRoutes
40
+ handleShopifyRoutes({
41
+ request,
42
+ requestContext,
43
+ storefrontClient,
44
+ sessionManager,
45
+ handlers: [cartHandlers, customerAccountHandlers, bagsy.handlers],
46
+ });
47
+ ```
48
+
49
+ ```tsx
50
+ // root.tsx
51
+ import { BagsyProvider } from '@umxr/bagsy/react';
52
+
53
+ export async function loader({ context }) {
54
+ const shopper = await bagsy.shopper({ sessionManager, requestContext }); // read-only: safe in loaders
55
+ return { wishlistCount: await shopper.count() };
56
+ }
57
+
58
+ export default function App({ loaderData }) {
59
+ return (
60
+ <BagsyProvider initialCount={loaderData.wishlistCount}>
61
+ <Outlet />
62
+ </BagsyProvider>
63
+ );
64
+ }
65
+ ```
66
+
67
+ ```tsx
68
+ // a collection page: one Bagsy call for the whole grid, rendered on the server
69
+ import { WishlistButton, useSeedSaved } from '@umxr/bagsy/react';
70
+
71
+ export async function loader({ context }) {
72
+ const products = await getProducts(context);
73
+ const shopper = await bagsy.shopper({ sessionManager, requestContext });
74
+ return { products, saved: (await shopper.saved(products.map((p) => p.id))).byProduct };
75
+ }
76
+
77
+ export default function Collection({ loaderData }) {
78
+ useSeedSaved(loaderData.saved);
79
+ return loaderData.products.map((product) => (
80
+ <a key={product.id} href={`/products/${product.handle}`}>
81
+ {product.title}
82
+ <WishlistButton productId={product.id} aria-label={`Save ${product.title}`}>
83
+ {({ saved }) => (saved ? '♥' : '♡')}
84
+ </WishlistButton>
85
+ </a>
86
+ ));
87
+ }
88
+ ```
89
+
90
+ How the shopper is identified:
91
+
92
+ - **Guests** get an id in your session on their first save. Browsing without saving costs no API call and writes nothing to the session.
93
+ - **Signed-in customers** are identified through the Customer Account API, once per sign-in.
94
+ - **Signing in** merges the guest's wishlist into the customer's.
95
+
96
+ What the relay accepts:
97
+
98
+ - **Requests:** only same-origin JSON `POST`s.
99
+ - **Calls:** only the ones a browser should make. Insights and identity merging are never forwarded.
100
+
101
+ ## React
102
+
103
+ ```tsx
104
+ const { saved, ready, pending, toggle } = useSaved(product.id, { variantId });
105
+ const count = useWishlistCount();
106
+ const { items, status, hasNextPage, loadMore, remove } = useWishlist({ pageSize: 24 });
107
+ ```
108
+
109
+ - **Batched lookups:** every heart that mounts in the same tick shares one lookup.
110
+ - **Optimistic toggles:** the UI updates at once and rolls back if the request fails.
111
+ - **Removing:** `remove(item)` on the wishlist page updates hearts and the count elsewhere too.
112
+ - **`<WishlistButton>` is unstyled:** style it with `[aria-pressed]`, `[data-saved]` and `[data-pending]`. It's safe inside product-card links.
113
+
114
+ ## The client
115
+
116
+ ```ts
117
+ import { createBagsy } from '@umxr/bagsy';
118
+
119
+ const bagsy = createBagsy({ mode: 'server', apiKey: process.env.BAGSY_API_KEY, baseUrl });
120
+ const shopper = bagsy.as({ customerId: 'gid://shopify/Customer/123' });
121
+
122
+ await shopper.items.toggle(8123, { variantId: 44123 }); // numeric ids or GIDs
123
+ const saved = await shopper.saved(productIds); // saved.has(id), saved.has(id, variantId)
124
+ for await (const item of shopper.items.iterate()) console.log(item.productId);
125
+ await bagsy.insights.topProducts({ since: '2026-10-01' });
126
+ ```
127
+
128
+ | Mode | Use |
129
+ | --- | --- |
130
+ | `server` | Node, Workers, Hydrogen loaders. Secret key. Throws if constructed in a browser. |
131
+ | `relay` | Browsers on headless storefronts. Calls your server's relay; `@umxr/bagsy/react` uses it. |
132
+ | `proxy` | Liquid themes, through the Shopify app proxy. Shopify identifies the customer. |
133
+ | `account` | Customer account UI extensions, with Shopify session tokens. |
134
+
135
+ Failed calls throw `BagsyError`. Use its `status` and its `code`, such as `list_not_found` or `owner_required`. Switch on `code`, not on the message.
136
+
137
+ ## Web components
138
+
139
+ In Liquid themes, the Bagsy app embed loads these for you, with no install. To use them elsewhere:
140
+
141
+ ```ts
142
+ import { define } from '@umxr/bagsy/element';
143
+ import '@umxr/bagsy/styles.css';
144
+ define();
145
+ ```
146
+
147
+ ```html
148
+ <bagsy-button product-id="8123" variant-source></bagsy-button>
149
+ <bagsy-count hide-empty></bagsy-count>
150
+ <bagsy-wishlist share></bagsy-wishlist>
151
+ ```
152
+
153
+ ## License
154
+
155
+ MIT
@@ -0,0 +1,191 @@
1
+ import {
2
+ BagsyError,
3
+ createBagsy,
4
+ createTransport,
5
+ productGid
6
+ } from "./chunk-OM72NJDX.js";
7
+
8
+ // ../hydrogen/src/server.ts
9
+ var SESSION_KEY = "bagsy";
10
+ var EMPTY_PAGE = { data: [], pageInfo: { hasNextPage: false, endCursor: null } };
11
+ function createBagsyServer(options) {
12
+ const endpoint = options.endpoint ?? "/api/bagsy";
13
+ const serverOptions = {
14
+ mode: "server",
15
+ apiKey: options.apiKey,
16
+ ...options.baseUrl && { baseUrl: options.baseUrl },
17
+ ...options.fetch && { fetch: options.fetch }
18
+ };
19
+ const base = createBagsy(serverOptions);
20
+ const transportFor = (owner) => createTransport({ ...serverOptions, ...owner });
21
+ async function shopper(context) {
22
+ context.requestContext.markResponseAsPersonalized?.("Bagsy wishlist");
23
+ const session = await readSession(context.sessionManager);
24
+ const customerId = await options.customer?.read(context);
25
+ const owners = [];
26
+ if (customerId) owners.push(base.as({ customerId }));
27
+ if (session.visitorId) owners.push(base.as({ visitorId: session.visitorId }));
28
+ return {
29
+ customerId,
30
+ async saved(products) {
31
+ const list = [...products];
32
+ const lookups = await Promise.all(owners.map((o) => o.saved(list)));
33
+ return unionLookup(list, lookups);
34
+ },
35
+ async count() {
36
+ const counts = await Promise.all(
37
+ owners.map(async (o) => (await o.lists.list()).find((l) => l.isDefault)?.itemCount ?? 0)
38
+ );
39
+ return counts.reduce((a, b) => a + b, 0);
40
+ },
41
+ async items(page = {}) {
42
+ if (!owners[0]) return EMPTY_PAGE;
43
+ return owners[0].items.list("default", page);
44
+ }
45
+ };
46
+ }
47
+ async function writableShopper(context) {
48
+ context.requestContext.markResponseAsPersonalized?.("Bagsy wishlist");
49
+ const session = await readSession(context.sessionManager);
50
+ const customerId = await options.customer?.write(context);
51
+ let write = Promise.resolve();
52
+ if (customerId && session.visitorId) {
53
+ try {
54
+ await base.as({ customerId }).identity.merge(session.visitorId);
55
+ delete session.visitorId;
56
+ await writeSession(context.sessionManager, session);
57
+ } catch (error) {
58
+ if (!(error instanceof BagsyError)) throw error;
59
+ }
60
+ }
61
+ const identityKnown = Boolean(customerId || session.visitorId);
62
+ const owner = () => {
63
+ if (customerId) return { customerId };
64
+ if (!session.visitorId) {
65
+ session.visitorId = crypto.randomUUID();
66
+ write = writeSession(context.sessionManager, session);
67
+ }
68
+ return { visitorId: session.visitorId };
69
+ };
70
+ let client;
71
+ return {
72
+ customerId,
73
+ get client() {
74
+ return client ??= base.as(owner());
75
+ },
76
+ identityKnown,
77
+ owner,
78
+ settled: () => write
79
+ };
80
+ }
81
+ async function relay(request, context) {
82
+ if (request.method !== "POST") return { rejected: { status: 405, code: "method_not_allowed", message: "Use POST." } };
83
+ if (!request.headers.get("Content-Type")?.toLowerCase().startsWith("application/json")) {
84
+ return { rejected: { status: 415, code: "unsupported_media_type", message: "Send application/json." } };
85
+ }
86
+ const origin = request.headers.get("Origin");
87
+ if (origin && origin !== new URL(request.url).origin) {
88
+ return { rejected: { status: 403, code: "forbidden_origin", message: "Cross-origin requests are not allowed." } };
89
+ }
90
+ let call;
91
+ try {
92
+ call = parseCall(await request.json());
93
+ } catch (error) {
94
+ return { rejected: { status: 400, code: "invalid_call", message: error instanceof Error ? error.message : "Invalid call." } };
95
+ }
96
+ const who = await writableShopper(context);
97
+ const owner = who.identityKnown || call.method !== "GET" ? who.owner() : { visitorId: crypto.randomUUID() };
98
+ try {
99
+ const { status, data } = await transportFor(owner).send(call.method, call.path, {
100
+ ...call.query && { query: call.query },
101
+ ...call.body !== void 0 && { body: call.body }
102
+ });
103
+ return { response: { status, data } };
104
+ } catch (error) {
105
+ if (!(error instanceof BagsyError)) throw error;
106
+ return { response: { status: error.status, data: error.problem ?? null } };
107
+ } finally {
108
+ await who.settled();
109
+ }
110
+ }
111
+ async function handle(request, context) {
112
+ if (new URL(request.url).pathname !== endpoint) return null;
113
+ const tracked = trackWrites(context.sessionManager);
114
+ const result = await relay(request, { ...context, sessionManager: tracked.sessionManager });
115
+ const headers = new Headers({ "Content-Type": "application/json", "Cache-Control": "no-store" });
116
+ if (tracked.dirty()) {
117
+ for (const [name, value] of new Headers(await context.sessionManager.commit?.() ?? void 0)) {
118
+ headers.append(name, value);
119
+ }
120
+ }
121
+ return "rejected" in result ? new Response(JSON.stringify({ error: { code: result.rejected.code, message: result.rejected.message } }), {
122
+ status: result.rejected.status,
123
+ headers
124
+ }) : new Response(JSON.stringify(result.response), { status: 200, headers });
125
+ }
126
+ const writable = writableShopper;
127
+ return { endpoint, shopper, writableShopper: writable, relay, handle };
128
+ }
129
+ var SEGMENT = "[A-Za-z0-9_-]{1,128}";
130
+ var ALLOWED = [
131
+ [/^\/lists$/, ["GET", "POST"]],
132
+ [new RegExp(`^/lists/${SEGMENT}$`), ["GET", "PATCH", "DELETE"]],
133
+ [new RegExp(`^/lists/${SEGMENT}/items$`), ["GET", "POST", "DELETE"]],
134
+ [new RegExp(`^/lists/${SEGMENT}/items/${SEGMENT}$`), ["PATCH", "DELETE"]],
135
+ [new RegExp(`^/lists/${SEGMENT}/share$`), ["POST", "DELETE"]],
136
+ [/^\/saved$/, ["GET"]],
137
+ [new RegExp(`^/shared/${SEGMENT}$`), ["GET"]],
138
+ [/^\/health$/, ["GET"]]
139
+ ];
140
+ function parseCall(value) {
141
+ if (typeof value !== "object" || value === null) throw new TypeError("Expected a JSON object.");
142
+ const { method, path, query, body } = value;
143
+ if (typeof method !== "string" || typeof path !== "string") throw new TypeError("`method` and `path` are required.");
144
+ const rule = ALLOWED.find(([pattern]) => pattern.test(path));
145
+ if (!rule || !rule[1].includes(method)) throw new TypeError(`${method} ${path} is not available to browsers.`);
146
+ const parsed = { method, path };
147
+ if (query !== void 0) {
148
+ if (typeof query !== "object" || query === null || Object.values(query).some((v) => typeof v !== "string")) {
149
+ throw new TypeError("`query` must map names to strings.");
150
+ }
151
+ const { visitor_id: _ignored, ...rest } = query;
152
+ parsed.query = rest;
153
+ }
154
+ if (body !== void 0) parsed.body = body;
155
+ return parsed;
156
+ }
157
+ function trackWrites(sessionManager) {
158
+ let dirty = false;
159
+ const tracked = Object.create(sessionManager);
160
+ tracked.getSessionItem = (key) => sessionManager.getSessionItem(key);
161
+ tracked.setSessionItem = (key, value) => (dirty = true, sessionManager.setSessionItem(key, value));
162
+ tracked.removeSessionItem = (key) => (dirty = true, sessionManager.removeSessionItem(key));
163
+ return { sessionManager: tracked, dirty: () => dirty };
164
+ }
165
+ async function readSession(sessionManager) {
166
+ const value = await sessionManager.getSessionItem(SESSION_KEY);
167
+ return value && typeof value === "object" ? { ...value } : {};
168
+ }
169
+ async function writeSession(sessionManager, value) {
170
+ if (value.visitorId) await sessionManager.setSessionItem(SESSION_KEY, { visitorId: value.visitorId });
171
+ else await sessionManager.removeSessionItem(SESSION_KEY);
172
+ }
173
+ function unionLookup(products, lookups) {
174
+ if (lookups.length === 1) return lookups[0];
175
+ const byProduct = {};
176
+ for (const product of products) {
177
+ const id = productGid(product);
178
+ byProduct[id] = lookups.flatMap((lookup) => lookup.get(id));
179
+ }
180
+ return {
181
+ byProduct,
182
+ get: (product) => byProduct[productGid(product)] ?? [],
183
+ has: (product, variant) => lookups.some((lookup) => lookup.has(product, variant))
184
+ };
185
+ }
186
+
187
+ export {
188
+ createBagsyServer,
189
+ trackWrites
190
+ };
191
+ //# sourceMappingURL=chunk-OHSBSGWH.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../hydrogen/src/server.ts"],"sourcesContent":["import {\n BagsyError,\n createBagsy,\n createTransport,\n productGid,\n type Bagsy,\n type ItemPage,\n type Owner,\n type PageOptions,\n type ProductRef,\n type RelayRequest,\n type RelayResponse,\n type SavedEntry,\n type SavedLookup,\n type ServerOptions,\n} from '@bagsy/core';\n\ntype Awaitable<T> = T | Promise<T>;\n\n/** A session you can only read: what loaders and server components get. */\nexport interface ReadonlySessionManager {\n getSessionItem(key: string): Awaitable<unknown>;\n}\n\n/** A session you can write and commit. Same shape as Hydrogen's `ShopifyRouteSessionManager`. */\nexport interface BagsySessionManager extends ReadonlySessionManager {\n setSessionItem(key: string, value: unknown): Awaitable<void>;\n removeSessionItem(key: string): Awaitable<void>;\n /** Saves changes and returns headers (a cookie) for the response. */\n commit?(): Awaitable<HeadersInit | void>;\n}\n\n/** The parts of Hydrogen's `ShopifyRequestContext` Bagsy uses. */\nexport interface BagsyRequestContext {\n markResponseAsPersonalized?(reason: string): void;\n}\n\n/** For reads (loaders): in Hydrogen, your request's session manager and `requestContext`. */\nexport interface ReadContext {\n sessionManager: ReadonlySessionManager;\n requestContext: BagsyRequestContext;\n}\n\n/** For writes: only where the response can commit the session (route handlers, actions). */\nexport interface WriteContext extends ReadContext {\n sessionManager: BagsySessionManager;\n}\n\n/** How Bagsy learns who is signed in. `hydrogenCustomerId()` implements it for Hydrogen. */\nexport interface CustomerResolver<R extends ReadContext = ReadContext, W extends WriteContext = WriteContext> {\n /** Without refreshing tokens or writing the session. */\n read(context: R): Promise<string | undefined>;\n /** May refresh tokens and cache in the session; the caller commits. */\n write(context: W): Promise<string | undefined>;\n}\n\nexport interface CreateBagsyServerOptions<R extends ReadContext = ReadContext, W extends WriteContext = WriteContext> {\n /** A server key from the Bagsy admin (Apps → Bagsy → Developers). Keep it server-side. */\n apiKey: string;\n /** Bagsy API base URL, e.g. `https://bagsy-api.example.workers.dev/v1`. */\n baseUrl?: string;\n /** Who is signed in. Omit for a guest-only storefront. */\n customer?: CustomerResolver<R, W>;\n /** Path the browser calls; `@umxr/bagsy/react` uses the same default. @default '/api/bagsy' */\n endpoint?: string;\n fetch?: typeof globalThis.fetch;\n}\n\n/** Read-only view of the shopper, for loaders and server rendering. Never writes the session. */\nexport interface Shopper {\n /** Signed-in customer GID, if any. */\n readonly customerId: string | undefined;\n /** Saved state for products. Guests who have never saved cost no API call. Pass `.byProduct` to `@umxr/bagsy/react`. */\n saved(products: Iterable<ProductRef>): Promise<SavedLookup>;\n /** Items in the default list, for a header badge. */\n count(): Promise<number>;\n /** A page of the default list, newest first. Pass to `useWishlist({ initial })`. */\n items(page?: PageOptions): Promise<ItemPage>;\n}\n\n/** The shopper with write access, for actions and route handlers that commit the session. */\nexport interface WritableShopper {\n readonly customerId: string | undefined;\n /**\n * A Bagsy client acting for this shopper. A guest without a wishlist gets\n * an id on first use, stored in the session: commit it with the response.\n */\n readonly client: Bagsy;\n}\n\n/** What Bagsy keeps in your storefront session. */\ninterface BagsySession {\n /** Guest wishlist owner, until they sign in and it's merged. */\n visitorId?: string;\n}\n\nconst SESSION_KEY = 'bagsy';\nconst EMPTY_PAGE: ItemPage = { data: [], pageInfo: { hasNextPage: false, endCursor: null } } as ItemPage;\n\n/**\n * Bagsy for Hydrogen and other server-rendered storefronts. Create one at\n * module scope; call `shopper()` in loaders and `relay`/`handle` for the\n * browser endpoint.\n */\nexport function createBagsyServer<R extends ReadContext = ReadContext, W extends WriteContext = WriteContext>(\n options: CreateBagsyServerOptions<R, W>,\n) {\n const endpoint = options.endpoint ?? '/api/bagsy';\n const serverOptions: ServerOptions = {\n mode: 'server',\n apiKey: options.apiKey,\n ...(options.baseUrl && { baseUrl: options.baseUrl }),\n ...(options.fetch && { fetch: options.fetch }),\n };\n const base = createBagsy(serverOptions);\n const transportFor = (owner: Owner) => createTransport({ ...serverOptions, ...owner });\n\n /** The shopper for this request, read-only. Safe in loaders and server components. */\n async function shopper(context: R): Promise<Shopper> {\n context.requestContext.markResponseAsPersonalized?.('Bagsy wishlist');\n const session = await readSession(context.sessionManager);\n const customerId = await options.customer?.read(context);\n // Signed in, but the guest list isn't merged yet (that happens on the next\n // write path): show both, so nothing seems to vanish for a page.\n const owners: Bagsy[] = [];\n if (customerId) owners.push(base.as({ customerId }));\n if (session.visitorId) owners.push(base.as({ visitorId: session.visitorId }));\n\n return {\n customerId,\n async saved(products) {\n const list = [...products];\n const lookups = await Promise.all(owners.map((o) => o.saved(list)));\n return unionLookup(list, lookups);\n },\n async count() {\n const counts = await Promise.all(\n owners.map(async (o) => (await o.lists.list()).find((l) => l.isDefault)?.itemCount ?? 0),\n );\n return counts.reduce((a, b) => a + b, 0);\n },\n async items(page = {}) {\n if (!owners[0]) return EMPTY_PAGE;\n return owners[0].items.list('default', page);\n },\n };\n }\n\n /**\n * The shopper with write access: merges a guest list into the customer's\n * after sign-in, and creates a guest id on first write. Use where the\n * response commits the session, e.g. a React Router action.\n */\n async function writableShopper(context: W): Promise<WritableShopper & InternalWritable> {\n context.requestContext.markResponseAsPersonalized?.('Bagsy wishlist');\n const session = await readSession(context.sessionManager);\n const customerId = await options.customer?.write(context);\n let write: Promise<void> = Promise.resolve();\n\n if (customerId && session.visitorId) {\n try {\n await base.as({ customerId }).identity.merge(session.visitorId);\n delete session.visitorId;\n await writeSession(context.sessionManager, session);\n } catch (error) {\n // Keep the guest id; the next write retries. Merging is idempotent.\n if (!(error instanceof BagsyError)) throw error;\n }\n }\n\n const identityKnown = Boolean(customerId || session.visitorId);\n const owner = (): Owner => {\n if (customerId) return { customerId };\n if (!session.visitorId) {\n session.visitorId = crypto.randomUUID();\n write = writeSession(context.sessionManager, session);\n }\n return { visitorId: session.visitorId };\n };\n let client: Bagsy | undefined;\n\n return {\n customerId,\n get client() {\n return (client ??= base.as(owner()));\n },\n identityKnown,\n owner,\n settled: () => write,\n };\n }\n\n /**\n * Run one call from the browser. Returns the API's status and body, or why\n * the call was rejected. Commit the session after (`handle` does).\n */\n async function relay(\n request: Request,\n context: W,\n ): Promise<{ response: RelayResponse } | { rejected: { status: number; code: string; message: string } }> {\n if (request.method !== 'POST') return { rejected: { status: 405, code: 'method_not_allowed', message: 'Use POST.' } };\n // Only same-origin scripts can send JSON: forms can't, and other origins\n // would need CORS, which this endpoint never grants.\n if (!request.headers.get('Content-Type')?.toLowerCase().startsWith('application/json')) {\n return { rejected: { status: 415, code: 'unsupported_media_type', message: 'Send application/json.' } };\n }\n const origin = request.headers.get('Origin');\n if (origin && origin !== new URL(request.url).origin) {\n return { rejected: { status: 403, code: 'forbidden_origin', message: 'Cross-origin requests are not allowed.' } };\n }\n\n let call: RelayRequest;\n try {\n call = parseCall(await request.json());\n } catch (error) {\n return { rejected: { status: 400, code: 'invalid_call', message: error instanceof Error ? error.message : 'Invalid call.' } };\n }\n\n const who = await writableShopper(context);\n // A guest who has never saved reads with a throwaway id, so browsing\n // doesn't create a session.\n const owner = who.identityKnown || call.method !== 'GET' ? who.owner() : { visitorId: crypto.randomUUID() };\n try {\n const { status, data } = await transportFor(owner).send(call.method, call.path, {\n ...(call.query && { query: call.query }),\n ...(call.body !== undefined && { body: call.body }),\n });\n return { response: { status, data } };\n } catch (error) {\n if (!(error instanceof BagsyError)) throw error;\n return { response: { status: error.status, data: error.problem ?? null } };\n } finally {\n await who.settled();\n }\n }\n\n /**\n * The relay endpoint as a plain `Request → Response` handler, for servers\n * without Hydrogen's route handlers. Returns null for other paths, and\n * commits the session if Bagsy changed it.\n */\n async function handle(request: Request, context: W): Promise<Response | null> {\n if (new URL(request.url).pathname !== endpoint) return null;\n const tracked = trackWrites(context.sessionManager);\n const result = await relay(request, { ...context, sessionManager: tracked.sessionManager });\n const headers = new Headers({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });\n if (tracked.dirty()) {\n for (const [name, value] of new Headers((await context.sessionManager.commit?.()) ?? undefined)) {\n headers.append(name, value);\n }\n }\n return 'rejected' in result\n ? new Response(JSON.stringify({ error: { code: result.rejected.code, message: result.rejected.message } }), {\n status: result.rejected.status,\n headers,\n })\n : new Response(JSON.stringify(result.response), { status: 200, headers });\n }\n\n const writable: (context: W) => Promise<WritableShopper> = writableShopper;\n return { endpoint, shopper, writableShopper: writable, relay, handle };\n}\n\nexport type BagsyServer<R extends ReadContext = ReadContext, W extends WriteContext = WriteContext> = ReturnType<\n typeof createBagsyServer<R, W>\n>;\n\ninterface InternalWritable {\n identityKnown: boolean;\n owner(): Owner;\n /** Resolves once any session write this shopper started has finished. */\n settled(): Promise<void>;\n}\n\n// ---------------------------------------------------------------------------\n\n/** Calls browsers may make, by path and method. Insights and identity stay server-side. */\nconst SEGMENT = '[A-Za-z0-9_-]{1,128}';\nconst ALLOWED: [RegExp, string[]][] = [\n [/^\\/lists$/, ['GET', 'POST']],\n [new RegExp(`^/lists/${SEGMENT}$`), ['GET', 'PATCH', 'DELETE']],\n [new RegExp(`^/lists/${SEGMENT}/items$`), ['GET', 'POST', 'DELETE']],\n [new RegExp(`^/lists/${SEGMENT}/items/${SEGMENT}$`), ['PATCH', 'DELETE']],\n [new RegExp(`^/lists/${SEGMENT}/share$`), ['POST', 'DELETE']],\n [/^\\/saved$/, ['GET']],\n [new RegExp(`^/shared/${SEGMENT}$`), ['GET']],\n [/^\\/health$/, ['GET']],\n];\n\nfunction parseCall(value: unknown): RelayRequest {\n if (typeof value !== 'object' || value === null) throw new TypeError('Expected a JSON object.');\n const { method, path, query, body } = value as Record<string, unknown>;\n if (typeof method !== 'string' || typeof path !== 'string') throw new TypeError('`method` and `path` are required.');\n const rule = ALLOWED.find(([pattern]) => pattern.test(path));\n if (!rule || !rule[1].includes(method)) throw new TypeError(`${method} ${path} is not available to browsers.`);\n const parsed: RelayRequest = { method, path };\n if (query !== undefined) {\n if (typeof query !== 'object' || query === null || Object.values(query).some((v) => typeof v !== 'string')) {\n throw new TypeError('`query` must map names to strings.');\n }\n // Identity comes from the session, never the browser.\n const { visitor_id: _ignored, ...rest } = query as Record<string, string>;\n parsed.query = rest;\n }\n if (body !== undefined) parsed.body = body;\n return parsed;\n}\n\n/** Wraps a session manager to report whether anything was written. */\nexport function trackWrites<SM extends BagsySessionManager>(sessionManager: SM) {\n let dirty = false;\n const tracked: SM = Object.create(sessionManager);\n tracked.getSessionItem = (key) => sessionManager.getSessionItem(key);\n tracked.setSessionItem = (key, value) => ((dirty = true), sessionManager.setSessionItem(key, value));\n tracked.removeSessionItem = (key) => ((dirty = true), sessionManager.removeSessionItem(key));\n return { sessionManager: tracked, dirty: () => dirty };\n}\n\nasync function readSession(sessionManager: ReadonlySessionManager): Promise<BagsySession> {\n const value = await sessionManager.getSessionItem(SESSION_KEY);\n return value && typeof value === 'object' ? { ...(value as BagsySession) } : {};\n}\n\nasync function writeSession(sessionManager: BagsySessionManager, value: BagsySession) {\n if (value.visitorId) await sessionManager.setSessionItem(SESSION_KEY, { visitorId: value.visitorId });\n else await sessionManager.removeSessionItem(SESSION_KEY);\n}\n\nfunction unionLookup(products: ProductRef[], lookups: SavedLookup[]): SavedLookup {\n if (lookups.length === 1) return lookups[0]!;\n const byProduct: Record<string, SavedEntry[]> = {};\n for (const product of products) {\n const id = productGid(product);\n byProduct[id] = lookups.flatMap((lookup) => lookup.get(id));\n }\n return {\n byProduct,\n get: (product) => byProduct[productGid(product)] ?? [],\n has: (product, variant) => lookups.some((lookup) => lookup.has(product, variant)),\n };\n}\n"],"mappings":"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","names":[]}