basketeer 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 +21 -0
- package/README.md +272 -0
- package/dist/auth/browser/playwright.d.ts +32 -0
- package/dist/auth/browser/playwright.js +150 -0
- package/dist/auth/harvest.d.ts +17 -0
- package/dist/auth/harvest.js +31 -0
- package/dist/auth/types.d.ts +19 -0
- package/dist/auth/types.js +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +211 -0
- package/dist/client.d.ts +167 -0
- package/dist/client.js +341 -0
- package/dist/errors.d.ts +40 -0
- package/dist/errors.js +64 -0
- package/dist/graphql.d.ts +53 -0
- package/dist/graphql.js +138 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +13 -0
- package/dist/jwt.d.ts +2 -0
- package/dist/jwt.js +13 -0
- package/dist/mcp-server.d.ts +32 -0
- package/dist/mcp-server.js +165 -0
- package/dist/models.d.ts +181 -0
- package/dist/models.js +2 -0
- package/dist/nutrition.d.ts +7 -0
- package/dist/nutrition.js +194 -0
- package/dist/operations.d.ts +46 -0
- package/dist/operations.js +46 -0
- package/dist/parsers.d.ts +25 -0
- package/dist/parsers.js +167 -0
- package/dist/queries.d.ts +18 -0
- package/dist/queries.js +235 -0
- package/dist/session-manager.d.ts +31 -0
- package/dist/session-manager.js +70 -0
- package/dist/store/file-store.d.ts +11 -0
- package/dist/store/file-store.js +32 -0
- package/dist/store/memory-store.d.ts +10 -0
- package/dist/store/memory-store.js +16 -0
- package/dist/store/types.d.ts +7 -0
- package/dist/store/types.js +1 -0
- package/package.json +87 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { BasketeerError, LineRejectedError, NotFoundError } from "./errors.js";
|
|
2
|
+
import { GraphQLTransport } from "./graphql.js";
|
|
3
|
+
import { filterByNutrition } from "./nutrition.js";
|
|
4
|
+
import { CHECKOUT_URL, FULFILMENT_TYPE, MFE, PENDING_ORDER_CONTEXTS, } from "./operations.js";
|
|
5
|
+
import { isoDate, parseBasket, parseBookedSlot, parseOrder, parseProduct, parseProductNode, parseSlot, } from "./parsers.js";
|
|
6
|
+
import { AMEND_ORDER, CANCEL_AMEND, CANCEL_ORDER, COLLECTION_SLOTS, DELIVERY_SLOTS, FULFILMENT, GET_BASKET, GET_CATEGORY_PRODUCTS, GET_FAVOURITES, GET_LAST_FULFILLED_ORDER, GET_PRODUCT, GET_UPCOMING_ORDERS, SEARCH, UPDATE_BASKET, } from "./queries.js";
|
|
7
|
+
import { SessionManager } from "./session-manager.js";
|
|
8
|
+
/** Guard for numeric SDK inputs. Throws `RangeError` for non-integer or out-of-range values. */
|
|
9
|
+
function reqInt(v, name, min) {
|
|
10
|
+
if (v === undefined)
|
|
11
|
+
return;
|
|
12
|
+
if (!Number.isInteger(v) || v < min)
|
|
13
|
+
throw new RangeError(`${name} must be an integer >= ${min}`);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Guard for a basket-line quantity (the low-level `basket.update` escape hatch).
|
|
17
|
+
* Rejects negative, NaN, and Infinity; allows fractional values for weight-priced
|
|
18
|
+
* lines. `JSON.stringify(NaN)` → `null`, which Tesco would silently misread.
|
|
19
|
+
*/
|
|
20
|
+
function reqQty(v, name) {
|
|
21
|
+
if (!Number.isFinite(v) || v < 0)
|
|
22
|
+
throw new RangeError(`${name} must be a finite number >= 0`);
|
|
23
|
+
}
|
|
24
|
+
function normaliseItem(item) {
|
|
25
|
+
return {
|
|
26
|
+
adjustment: item.adjustment ?? false,
|
|
27
|
+
id: String(item.id),
|
|
28
|
+
newValue: item.newValue,
|
|
29
|
+
newUnitChoice: item.newUnitChoice ?? "pcs",
|
|
30
|
+
substitutionOption: item.substitutionOption ?? "FindSuitableAlternative",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export class Basketeer {
|
|
34
|
+
transport;
|
|
35
|
+
sessions;
|
|
36
|
+
_amendingOrderNo = null;
|
|
37
|
+
constructor(opts = {}) {
|
|
38
|
+
this.sessions = new SessionManager({
|
|
39
|
+
session: opts.session,
|
|
40
|
+
backend: opts.authBackend,
|
|
41
|
+
store: opts.store,
|
|
42
|
+
});
|
|
43
|
+
this.transport = new GraphQLTransport({
|
|
44
|
+
apiKey: opts.apiKey,
|
|
45
|
+
throttleMs: opts.throttleMs,
|
|
46
|
+
fetchImpl: opts.fetchImpl,
|
|
47
|
+
getAuth: () => this.sessions.authHeaders(),
|
|
48
|
+
onUnauthorized: () => this.sessions.refresh(),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Resume a persisted session, refreshing proactively if it has expired. */
|
|
52
|
+
static async resume(opts = {}) {
|
|
53
|
+
const client = new Basketeer(opts);
|
|
54
|
+
await client.sessions.load();
|
|
55
|
+
if (client.sessions.isExpired() && opts.authBackend) {
|
|
56
|
+
await client.sessions.refresh();
|
|
57
|
+
}
|
|
58
|
+
return client;
|
|
59
|
+
}
|
|
60
|
+
/** Mint a new session via the configured AuthBackend. */
|
|
61
|
+
async login(credentials) {
|
|
62
|
+
return this.sessions.login(credentials);
|
|
63
|
+
}
|
|
64
|
+
/** The current session, or null if unauthenticated. */
|
|
65
|
+
get session() {
|
|
66
|
+
return this.sessions.current;
|
|
67
|
+
}
|
|
68
|
+
/** True when a (non-expired) session is loaded. */
|
|
69
|
+
get isAuthenticated() {
|
|
70
|
+
return this.sessions.current !== null && !this.sessions.isExpired();
|
|
71
|
+
}
|
|
72
|
+
/** The order currently open for amendment via `orders.amend`, if any. */
|
|
73
|
+
get amendingOrderNo() {
|
|
74
|
+
return this._amendingOrderNo;
|
|
75
|
+
}
|
|
76
|
+
// --- reads (anonymous) ----------------------------------------------------
|
|
77
|
+
async getProduct(sku) {
|
|
78
|
+
const data = await this.transport.execute({
|
|
79
|
+
operationName: "GetProduct",
|
|
80
|
+
query: GET_PRODUCT,
|
|
81
|
+
variables: { tpnc: String(sku) },
|
|
82
|
+
mfeName: MFE.product,
|
|
83
|
+
});
|
|
84
|
+
if (!data.product)
|
|
85
|
+
throw new NotFoundError(`No product found for SKU ${sku}`);
|
|
86
|
+
return parseProduct(data.product);
|
|
87
|
+
}
|
|
88
|
+
async search(query, opts = {}) {
|
|
89
|
+
reqInt(opts.limit, "limit", 1);
|
|
90
|
+
reqInt(opts.page, "page", 1);
|
|
91
|
+
const data = await this.transport.execute({
|
|
92
|
+
operationName: "Search",
|
|
93
|
+
query: SEARCH,
|
|
94
|
+
variables: { query, count: opts.limit ?? 10, page: opts.page ?? 1 },
|
|
95
|
+
mfeName: MFE.search,
|
|
96
|
+
});
|
|
97
|
+
return this.page(data.search?.results, (e) => e.node, opts);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Keyword search, then hydrate the top `hydrate` results' nutrition (each a throttled
|
|
101
|
+
* product fetch) and filter/rank locally. The cost is bounded by `hydrate` (default 20)
|
|
102
|
+
* and reported honestly: `hydrated` is how many products were fetched, `failed` how many
|
|
103
|
+
* detail fetches errored (and were skipped, not fatal), and `hasMore` whether the
|
|
104
|
+
* catalogue had more keyword matches than were hydrated.
|
|
105
|
+
*/
|
|
106
|
+
async searchByNutrition(query, opts = {}) {
|
|
107
|
+
reqInt(opts.hydrate, "hydrate", 1);
|
|
108
|
+
reqInt(opts.limit, "limit", 1);
|
|
109
|
+
const cap = opts.hydrate ?? 20;
|
|
110
|
+
const page = await this.search(query, { limit: Math.max(cap, opts.limit ?? 0) });
|
|
111
|
+
const head = page.results.slice(0, cap);
|
|
112
|
+
const hydrated = [];
|
|
113
|
+
let failed = 0;
|
|
114
|
+
for (const r of head) {
|
|
115
|
+
try {
|
|
116
|
+
hydrated.push(await this.getProduct(r.sku)); // serial — relies on the 1 req/s transport throttle
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (err instanceof NotFoundError) {
|
|
120
|
+
failed++;
|
|
121
|
+
continue;
|
|
122
|
+
} // discontinued/regional SKU 404s — soft-skip
|
|
123
|
+
throw err; // rate-limit, bad key, auth, transport, schema — these are real; don't hide them
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let results = filterByNutrition(hydrated, { where: opts.where, sort: opts.sort });
|
|
127
|
+
if (opts.limit != null)
|
|
128
|
+
results = results.slice(0, opts.limit);
|
|
129
|
+
return { results, hydrated: hydrated.length, failed, hasMore: page.hasMore };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* List products in a category. Build `facet` with {@link categoryFacet} (or
|
|
133
|
+
* lift it from a Tesco category-page URL `?...&facet=…`).
|
|
134
|
+
*/
|
|
135
|
+
async browseCategory(facet, opts = {}) {
|
|
136
|
+
reqInt(opts.limit, "limit", 1);
|
|
137
|
+
reqInt(opts.page, "page", 1);
|
|
138
|
+
const data = await this.transport.execute({
|
|
139
|
+
operationName: "GetCategoryProducts",
|
|
140
|
+
query: GET_CATEGORY_PRODUCTS,
|
|
141
|
+
variables: { facet, count: opts.limit ?? 24, page: opts.page ?? 1 },
|
|
142
|
+
mfeName: MFE.search,
|
|
143
|
+
});
|
|
144
|
+
return this.page(data.category?.results, (e) => e.node, { limit: 24, ...opts });
|
|
145
|
+
}
|
|
146
|
+
/** The customer's favourites ("my usuals"). */
|
|
147
|
+
async favourites(opts = {}) {
|
|
148
|
+
reqInt(opts.limit, "limit", 1);
|
|
149
|
+
reqInt(opts.page, "page", 1);
|
|
150
|
+
const data = await this.transport.execute({
|
|
151
|
+
operationName: "GetFavourites",
|
|
152
|
+
query: GET_FAVOURITES,
|
|
153
|
+
variables: { count: opts.limit ?? 50, page: opts.page ?? 1, sortBy: "TAXONOMY" },
|
|
154
|
+
mfeName: MFE.favourites,
|
|
155
|
+
});
|
|
156
|
+
return this.page(data.favourites?.products, (p) => p, { limit: 50, ...opts });
|
|
157
|
+
}
|
|
158
|
+
/** Shared paging: map nodes → SearchResult, drop nulls, derive `hasMore`. */
|
|
159
|
+
page(raw, node, opts) {
|
|
160
|
+
const pageSize = opts.limit ?? 10;
|
|
161
|
+
const rows = raw ?? [];
|
|
162
|
+
const results = rows
|
|
163
|
+
.map((r) => parseProductNode(node(r)))
|
|
164
|
+
.filter((r) => r !== null);
|
|
165
|
+
return { results, page: opts.page ?? 1, pageSize, hasMore: rows.length >= pageSize };
|
|
166
|
+
}
|
|
167
|
+
// --- basket (requires auth) -----------------------------------------------
|
|
168
|
+
basket = {
|
|
169
|
+
get: () => this.getBasket(),
|
|
170
|
+
/**
|
|
171
|
+
* Increment the line for `sku` by `quantity` (default 1). Reads the current
|
|
172
|
+
* quantity first, so it costs an extra request and is not safe under
|
|
173
|
+
* concurrent edits — use `set` for exact, idempotent quantities.
|
|
174
|
+
*/
|
|
175
|
+
add: async (sku, quantity = 1, unit = "pcs") => {
|
|
176
|
+
if (!Number.isInteger(quantity) || quantity < 1)
|
|
177
|
+
throw new RangeError("quantity must be an integer >= 1");
|
|
178
|
+
const current = (await this.getBasket()).items.find((i) => i.sku === sku)?.quantity ?? 0;
|
|
179
|
+
return this.updateBasket([{ id: sku, newValue: current + quantity, newUnitChoice: unit }]);
|
|
180
|
+
},
|
|
181
|
+
/** Set the line for `sku` to an exact `quantity` (0 removes it). */
|
|
182
|
+
set: (sku, quantity, unit = "pcs") => {
|
|
183
|
+
if (!Number.isInteger(quantity) || quantity < 0)
|
|
184
|
+
throw new RangeError("quantity must be an integer >= 0");
|
|
185
|
+
return this.updateBasket([{ id: sku, newValue: quantity, newUnitChoice: unit }]);
|
|
186
|
+
},
|
|
187
|
+
/** Remove `sku` from the basket. */
|
|
188
|
+
remove: (sku) => this.updateBasket([{ id: sku, newValue: 0 }]),
|
|
189
|
+
/** Low-level: send raw basket line inputs (and an optional orderId). */
|
|
190
|
+
update: (items, orderId) => this.updateBasket(items, orderId),
|
|
191
|
+
};
|
|
192
|
+
async getBasket() {
|
|
193
|
+
const data = await this.transport.execute({
|
|
194
|
+
operationName: "GetBasket",
|
|
195
|
+
query: GET_BASKET,
|
|
196
|
+
variables: {},
|
|
197
|
+
mfeName: MFE.basket,
|
|
198
|
+
});
|
|
199
|
+
return parseBasket(data.basket);
|
|
200
|
+
}
|
|
201
|
+
async updateBasket(items, orderId) {
|
|
202
|
+
for (const item of items)
|
|
203
|
+
reqQty(item.newValue, "quantity");
|
|
204
|
+
const variables = { items: items.map(normaliseItem) };
|
|
205
|
+
if (orderId !== undefined)
|
|
206
|
+
variables.orderId = orderId;
|
|
207
|
+
const data = await this.transport.execute({
|
|
208
|
+
operationName: "UpdateBasket",
|
|
209
|
+
query: UPDATE_BASKET,
|
|
210
|
+
variables,
|
|
211
|
+
mfeName: MFE.basket,
|
|
212
|
+
});
|
|
213
|
+
const updates = data.basket?.updates?.items ?? [];
|
|
214
|
+
const rejected = updates.filter((u) => u.successful === false).map((u) => String(u.id));
|
|
215
|
+
if (rejected.length)
|
|
216
|
+
throw new LineRejectedError(rejected.join(", "));
|
|
217
|
+
return parseBasket(data.basket);
|
|
218
|
+
}
|
|
219
|
+
// --- orders (requires auth) -----------------------------------------------
|
|
220
|
+
orders = {
|
|
221
|
+
/** List upcoming (pending) orders with their items, slot, and amend window. */
|
|
222
|
+
list: (opts = {}) => this.listOrders(opts),
|
|
223
|
+
/**
|
|
224
|
+
* Open an order for amendment. Returns an {@link Amendment} handle whose
|
|
225
|
+
* basket ops apply to THAT order; finish by checking out again, or call
|
|
226
|
+
* `discard()`. Only works before the order's `amendExpiry`. While amending,
|
|
227
|
+
* `client.amendingOrderNo` reports the open order.
|
|
228
|
+
*/
|
|
229
|
+
amend: (orderNo) => this.amendOrder(orderNo),
|
|
230
|
+
/** Cancel an order outright (before its amend/cancel cutoff). */
|
|
231
|
+
cancel: (orderNo) => this.cancelOrder(orderNo),
|
|
232
|
+
/** The last delivered order (its items), for "reorder my usual shop". */
|
|
233
|
+
lastFulfilled: () => this.lastFulfilledOrder(),
|
|
234
|
+
};
|
|
235
|
+
async amendOrder(orderNo) {
|
|
236
|
+
await this.orderAction(AMEND_ORDER, "AmendOrder", orderNo);
|
|
237
|
+
this._amendingOrderNo = orderNo;
|
|
238
|
+
return {
|
|
239
|
+
orderNo,
|
|
240
|
+
get: () => this.getBasket(),
|
|
241
|
+
set: (sku, quantity, unit) => this.basket.set(sku, quantity, unit),
|
|
242
|
+
remove: (sku) => this.basket.remove(sku),
|
|
243
|
+
discard: async () => {
|
|
244
|
+
await this.orderAction(CANCEL_AMEND, "CancelAmend", orderNo);
|
|
245
|
+
if (this._amendingOrderNo === orderNo)
|
|
246
|
+
this._amendingOrderNo = null;
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
async cancelOrder(orderNo) {
|
|
251
|
+
await this.orderAction(CANCEL_ORDER, "CancelOrder", orderNo);
|
|
252
|
+
if (this._amendingOrderNo === orderNo)
|
|
253
|
+
this._amendingOrderNo = null;
|
|
254
|
+
}
|
|
255
|
+
async lastFulfilledOrder() {
|
|
256
|
+
const data = await this.transport.execute({
|
|
257
|
+
operationName: "GetLastFulfilledOrder",
|
|
258
|
+
query: GET_LAST_FULFILLED_ORDER,
|
|
259
|
+
variables: { status: "LastFulfilled" },
|
|
260
|
+
mfeName: MFE.orders,
|
|
261
|
+
});
|
|
262
|
+
return data.order ? parseOrder(data.order) : null;
|
|
263
|
+
}
|
|
264
|
+
async listOrders(opts) {
|
|
265
|
+
const data = await this.transport.execute({
|
|
266
|
+
operationName: "GetUpcomingOrders",
|
|
267
|
+
query: GET_UPCOMING_ORDERS,
|
|
268
|
+
variables: { orderContexts: opts.contexts ?? PENDING_ORDER_CONTEXTS },
|
|
269
|
+
mfeName: MFE.orders,
|
|
270
|
+
});
|
|
271
|
+
return (data.orderSearch?.orders ?? []).map(parseOrder);
|
|
272
|
+
}
|
|
273
|
+
async orderAction(query, operationName, orderNo) {
|
|
274
|
+
await this.transport.execute({
|
|
275
|
+
operationName,
|
|
276
|
+
query,
|
|
277
|
+
variables: { orderNo },
|
|
278
|
+
mfeName: MFE.orders,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// --- checkout (boundary: pure HTTP up to payment) -------------------------
|
|
282
|
+
/**
|
|
283
|
+
* Prepare for payment. Returns the current basket and the URL where payment
|
|
284
|
+
* is completed **in a browser**. basketeer deliberately stops here:
|
|
285
|
+
* Tesco's payment step is a separate CSRF-protected checkout app + 3-D Secure
|
|
286
|
+
* card authentication — browser-bound by design and PCI/fraud-sensitive. Fill
|
|
287
|
+
* the basket and book a slot via this SDK, then complete payment at `url`.
|
|
288
|
+
*/
|
|
289
|
+
async checkout() {
|
|
290
|
+
return { basket: await this.getBasket(), url: CHECKOUT_URL };
|
|
291
|
+
}
|
|
292
|
+
// --- slots (requires auth) ------------------------------------------------
|
|
293
|
+
slots = {
|
|
294
|
+
/** List delivery slots over a date window (default today..+6 days). */
|
|
295
|
+
list: (opts = {}) => this.listSlots(opts),
|
|
296
|
+
/** List click-and-collect slots for a store (`locationUuid`); default today..+6 days. */
|
|
297
|
+
listCollection: (opts = {}) => this.listCollectionSlots(opts),
|
|
298
|
+
/** Reserve a slot by id. Held until `reservationExpiry`; check out before then. */
|
|
299
|
+
book: (slotId) => this.fulfilment(slotId, "BOOK"),
|
|
300
|
+
/** Release a previously-reserved slot. */
|
|
301
|
+
release: (slotId) => this.fulfilment(slotId, "UNBOOK"),
|
|
302
|
+
};
|
|
303
|
+
async listSlots(opts) {
|
|
304
|
+
const data = await this.transport.execute({
|
|
305
|
+
operationName: "DeliverySlots",
|
|
306
|
+
query: DELIVERY_SLOTS,
|
|
307
|
+
variables: {
|
|
308
|
+
start: opts.start ?? isoDate(0),
|
|
309
|
+
end: opts.end ?? isoDate(6),
|
|
310
|
+
type: opts.type ?? FULFILMENT_TYPE.delivery,
|
|
311
|
+
},
|
|
312
|
+
mfeName: MFE.slots,
|
|
313
|
+
});
|
|
314
|
+
return (data.delivery ?? []).map(parseSlot);
|
|
315
|
+
}
|
|
316
|
+
async listCollectionSlots(opts) {
|
|
317
|
+
const data = await this.transport.execute({
|
|
318
|
+
operationName: "CollectionSlots",
|
|
319
|
+
query: COLLECTION_SLOTS,
|
|
320
|
+
variables: {
|
|
321
|
+
start: opts.start ?? isoDate(0),
|
|
322
|
+
end: opts.end ?? isoDate(6),
|
|
323
|
+
locationUuid: opts.locationUuid ?? null,
|
|
324
|
+
},
|
|
325
|
+
mfeName: MFE.slots,
|
|
326
|
+
});
|
|
327
|
+
return (data.collection ?? []).map(parseSlot);
|
|
328
|
+
}
|
|
329
|
+
async fulfilment(slotId, action) {
|
|
330
|
+
const data = await this.transport.execute({
|
|
331
|
+
operationName: "Fulfilment",
|
|
332
|
+
query: FULFILMENT,
|
|
333
|
+
variables: { slotId, action },
|
|
334
|
+
mfeName: MFE.slots,
|
|
335
|
+
});
|
|
336
|
+
const slot = data.fulfilment?.slot;
|
|
337
|
+
if (!slot)
|
|
338
|
+
throw new BasketeerError(`Slot ${action} returned no slot`);
|
|
339
|
+
return parseBookedSlot(slot);
|
|
340
|
+
}
|
|
341
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Base class for every error this library throws. */
|
|
2
|
+
export declare class BasketeerError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The session could not be renewed (refresh failed or no session present).
|
|
7
|
+
* Callers must re-authenticate — interactively for OSS users, or via the
|
|
8
|
+
* consumer's own re-login flow.
|
|
9
|
+
*/
|
|
10
|
+
export declare class AuthExpiredError extends BasketeerError {
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Tesco returned 429/403 (rate limited or bot-blocked). The client never
|
|
14
|
+
* retry-storms: it surfaces this so callers back off. Honour polite usage.
|
|
15
|
+
*/
|
|
16
|
+
export declare class RateLimitedError extends BasketeerError {
|
|
17
|
+
readonly status: number;
|
|
18
|
+
constructor(status: number, message: string);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The gateway rejected the `x-apikey` (HTTP 403, body `Forbidden: Invalid
|
|
22
|
+
* Client`). The public key rotates ~monthly — set `TESCO_API_KEY` or update
|
|
23
|
+
* `PUBLIC_API_KEY`. This is a configuration error, never retryable.
|
|
24
|
+
*/
|
|
25
|
+
export declare class ApiKeyError extends BasketeerError {
|
|
26
|
+
}
|
|
27
|
+
/** Thrown when a requested resource does not exist (e.g. an unknown SKU). */
|
|
28
|
+
export declare class NotFoundError extends BasketeerError {
|
|
29
|
+
}
|
|
30
|
+
/** The GraphQL response carried an `errors` array (non-auth). */
|
|
31
|
+
export declare class GraphQLRequestError extends BasketeerError {
|
|
32
|
+
/** The raw GraphQL errors, for programmatic inspection. */
|
|
33
|
+
readonly errors: unknown[];
|
|
34
|
+
constructor(errors: unknown[]);
|
|
35
|
+
}
|
|
36
|
+
/** A basket line update was rejected by Tesco (updates.items[].successful === false). */
|
|
37
|
+
export declare class LineRejectedError extends BasketeerError {
|
|
38
|
+
readonly lineId: string;
|
|
39
|
+
constructor(lineId: string);
|
|
40
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Base class for every error this library throws. */
|
|
2
|
+
export class BasketeerError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = new.target.name;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The session could not be renewed (refresh failed or no session present).
|
|
10
|
+
* Callers must re-authenticate — interactively for OSS users, or via the
|
|
11
|
+
* consumer's own re-login flow.
|
|
12
|
+
*/
|
|
13
|
+
export class AuthExpiredError extends BasketeerError {
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Tesco returned 429/403 (rate limited or bot-blocked). The client never
|
|
17
|
+
* retry-storms: it surfaces this so callers back off. Honour polite usage.
|
|
18
|
+
*/
|
|
19
|
+
export class RateLimitedError extends BasketeerError {
|
|
20
|
+
status;
|
|
21
|
+
constructor(status, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The gateway rejected the `x-apikey` (HTTP 403, body `Forbidden: Invalid
|
|
28
|
+
* Client`). The public key rotates ~monthly — set `TESCO_API_KEY` or update
|
|
29
|
+
* `PUBLIC_API_KEY`. This is a configuration error, never retryable.
|
|
30
|
+
*/
|
|
31
|
+
export class ApiKeyError extends BasketeerError {
|
|
32
|
+
}
|
|
33
|
+
/** Thrown when a requested resource does not exist (e.g. an unknown SKU). */
|
|
34
|
+
export class NotFoundError extends BasketeerError {
|
|
35
|
+
}
|
|
36
|
+
/** The GraphQL response carried an `errors` array (non-auth). */
|
|
37
|
+
export class GraphQLRequestError extends BasketeerError {
|
|
38
|
+
/** The raw GraphQL errors, for programmatic inspection. */
|
|
39
|
+
errors;
|
|
40
|
+
constructor(errors) {
|
|
41
|
+
// Keep the message short and scrubbed — the raw array may echo request
|
|
42
|
+
// context. Full detail stays on `.errors` for programmatic access; callers
|
|
43
|
+
// should avoid logging the whole object.
|
|
44
|
+
const first = errors[0]?.message;
|
|
45
|
+
const summary = typeof first === "string" ? first : "request failed";
|
|
46
|
+
const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : "";
|
|
47
|
+
super(`Tesco GraphQL error: ${scrubSecrets(summary)}${more}`);
|
|
48
|
+
this.errors = errors;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Redact anything that looks like a bearer/JWT/cookie from a message. */
|
|
52
|
+
function scrubSecrets(s) {
|
|
53
|
+
return s
|
|
54
|
+
.replace(/eyJ[A-Za-z0-9_-]{10,}/g, "«jwt»")
|
|
55
|
+
.replace(/(OAuth\.[A-Za-z]+|_abck|bm_[a-z]+)=[^;\s]+/gi, "$1=«redacted»");
|
|
56
|
+
}
|
|
57
|
+
/** A basket line update was rejected by Tesco (updates.items[].successful === false). */
|
|
58
|
+
export class LineRejectedError extends BasketeerError {
|
|
59
|
+
lineId;
|
|
60
|
+
constructor(lineId) {
|
|
61
|
+
super(`Tesco rejected basket line ${lineId}`);
|
|
62
|
+
this.lineId = lineId;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Auth material injected on writes. Undefined for anonymous reads. */
|
|
2
|
+
export interface AuthHeaders {
|
|
3
|
+
authorization: string;
|
|
4
|
+
customerUuid: string;
|
|
5
|
+
/** Pre-serialised `Cookie` header value (auth + Akamai cookies). */
|
|
6
|
+
cookie?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface GraphQLOp {
|
|
9
|
+
operationName: string;
|
|
10
|
+
query: string;
|
|
11
|
+
variables: Record<string, unknown>;
|
|
12
|
+
mfeName: string;
|
|
13
|
+
}
|
|
14
|
+
export interface TransportOptions {
|
|
15
|
+
endpoint?: string;
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
userAgent?: string;
|
|
18
|
+
/** Minimum gap between requests in ms (polite usage). Default 1000. */
|
|
19
|
+
throttleMs?: number;
|
|
20
|
+
/** Injected for tests; defaults to global fetch. */
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
/** Returns current auth headers, or undefined for anonymous. */
|
|
23
|
+
getAuth?: () => AuthHeaders | undefined;
|
|
24
|
+
/** Called once on a 401; return true if a refresh succeeded (then we retry). */
|
|
25
|
+
onUnauthorized?: () => Promise<boolean>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Pure-HTTP transport for `xapi.tesco.com`. Builds the JSON-array request body,
|
|
29
|
+
* assembles the header set Tesco expects, throttles to a polite rate, retries
|
|
30
|
+
* once on a 401 (after refresh), and refuses to retry-storm a 429/403.
|
|
31
|
+
*/
|
|
32
|
+
export declare class GraphQLTransport {
|
|
33
|
+
private readonly endpoint;
|
|
34
|
+
private readonly apiKey;
|
|
35
|
+
private readonly userAgent;
|
|
36
|
+
private readonly throttleMs;
|
|
37
|
+
private readonly fetchImpl;
|
|
38
|
+
private readonly getAuth;
|
|
39
|
+
private readonly onUnauthorized?;
|
|
40
|
+
/** Serial gate: each request awaits the previous one's scheduled slot. */
|
|
41
|
+
private gate;
|
|
42
|
+
private nextAllowedAt;
|
|
43
|
+
constructor(opts?: TransportOptions);
|
|
44
|
+
/** Run one operation; returns its `data` object. Retries once on a 401. */
|
|
45
|
+
execute<T = Record<string, unknown>>(op: GraphQLOp): Promise<T>;
|
|
46
|
+
private post;
|
|
47
|
+
/**
|
|
48
|
+
* Hand out request slots serially. Each call chains onto the previous one's
|
|
49
|
+
* scheduled slot, so N concurrent callers (e.g. `Promise.all`) are spaced
|
|
50
|
+
* `throttleMs` apart rather than all firing at once.
|
|
51
|
+
*/
|
|
52
|
+
private throttle;
|
|
53
|
+
}
|
package/dist/graphql.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { ApiKeyError, AuthExpiredError, GraphQLRequestError, RateLimitedError } from "./errors.js";
|
|
3
|
+
import { ENDPOINT, PUBLIC_API_KEY } from "./operations.js";
|
|
4
|
+
const DEFAULT_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
5
|
+
"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36";
|
|
6
|
+
function isUnauthorized(errors) {
|
|
7
|
+
for (const e of errors) {
|
|
8
|
+
const err = e;
|
|
9
|
+
// Synthetic 401 envelope injected by post() for raw HTTP 401 responses.
|
|
10
|
+
if (err.status === 401)
|
|
11
|
+
return true;
|
|
12
|
+
if (typeof err.message === "string" && /unauthor|401/i.test(err.message))
|
|
13
|
+
return true;
|
|
14
|
+
// GraphQL extension checks (existing).
|
|
15
|
+
if (err.extensions?.http?.status === 401)
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
function uuid() {
|
|
21
|
+
return randomUUID();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Pure-HTTP transport for `xapi.tesco.com`. Builds the JSON-array request body,
|
|
25
|
+
* assembles the header set Tesco expects, throttles to a polite rate, retries
|
|
26
|
+
* once on a 401 (after refresh), and refuses to retry-storm a 429/403.
|
|
27
|
+
*/
|
|
28
|
+
export class GraphQLTransport {
|
|
29
|
+
endpoint;
|
|
30
|
+
apiKey;
|
|
31
|
+
userAgent;
|
|
32
|
+
throttleMs;
|
|
33
|
+
fetchImpl;
|
|
34
|
+
getAuth;
|
|
35
|
+
onUnauthorized;
|
|
36
|
+
/** Serial gate: each request awaits the previous one's scheduled slot. */
|
|
37
|
+
gate = Promise.resolve();
|
|
38
|
+
nextAllowedAt = 0;
|
|
39
|
+
constructor(opts = {}) {
|
|
40
|
+
this.endpoint = opts.endpoint ?? ENDPOINT;
|
|
41
|
+
this.apiKey = opts.apiKey ?? process.env.TESCO_API_KEY ?? PUBLIC_API_KEY;
|
|
42
|
+
this.userAgent = opts.userAgent ?? DEFAULT_UA;
|
|
43
|
+
this.throttleMs = opts.throttleMs ?? 1000;
|
|
44
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
45
|
+
this.getAuth = opts.getAuth ?? (() => undefined);
|
|
46
|
+
this.onUnauthorized = opts.onUnauthorized;
|
|
47
|
+
}
|
|
48
|
+
/** Run one operation; returns its `data` object. Retries once on a 401. */
|
|
49
|
+
async execute(op) {
|
|
50
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
51
|
+
await this.throttle();
|
|
52
|
+
const result = await this.post(op);
|
|
53
|
+
const errors = (result.errors ?? []);
|
|
54
|
+
if (errors.length && isUnauthorized(errors)) {
|
|
55
|
+
// First 401: refresh and retry once. Persistent 401: the session is dead.
|
|
56
|
+
if (attempt === 0 && this.onUnauthorized && (await this.onUnauthorized()))
|
|
57
|
+
continue;
|
|
58
|
+
throw new AuthExpiredError("Tesco auth expired and could not be refreshed — re-authenticate.");
|
|
59
|
+
}
|
|
60
|
+
if (errors.length)
|
|
61
|
+
throw new GraphQLRequestError(errors);
|
|
62
|
+
return (result.data ?? {});
|
|
63
|
+
}
|
|
64
|
+
// Only reached if attempt 1 returned no errors above; keep TS happy.
|
|
65
|
+
throw new AuthExpiredError("Tesco auth expired and could not be refreshed — re-authenticate.");
|
|
66
|
+
}
|
|
67
|
+
async post(op) {
|
|
68
|
+
const auth = this.getAuth();
|
|
69
|
+
const headers = {
|
|
70
|
+
"user-agent": this.userAgent,
|
|
71
|
+
accept: "application/json",
|
|
72
|
+
"content-type": "application/json",
|
|
73
|
+
"accept-language": "en-GB",
|
|
74
|
+
"x-apikey": this.apiKey,
|
|
75
|
+
region: "UK",
|
|
76
|
+
language: "en-GB",
|
|
77
|
+
traceid: `${uuid()}:${uuid()}`,
|
|
78
|
+
trkid: uuid(),
|
|
79
|
+
};
|
|
80
|
+
if (auth) {
|
|
81
|
+
headers["authorization"] = auth.authorization;
|
|
82
|
+
headers["customer-uuid"] = auth.customerUuid;
|
|
83
|
+
if (auth.cookie)
|
|
84
|
+
headers["cookie"] = auth.cookie;
|
|
85
|
+
}
|
|
86
|
+
const body = JSON.stringify([
|
|
87
|
+
{
|
|
88
|
+
operationName: op.operationName,
|
|
89
|
+
variables: op.variables,
|
|
90
|
+
extensions: { mfeName: op.mfeName },
|
|
91
|
+
query: op.query,
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
const res = await this.fetchImpl(this.endpoint, { method: "POST", headers, body });
|
|
95
|
+
// Read the body once. Non-2xx responses are often plain text, not JSON.
|
|
96
|
+
const text = await res.text();
|
|
97
|
+
if (res.status === 401) {
|
|
98
|
+
// Raw HTTP 401 (often non-JSON). Surface as unauthorized so execute() refreshes + retries.
|
|
99
|
+
return { errors: [{ message: "HTTP 401 Unauthorized", status: 401 }] };
|
|
100
|
+
}
|
|
101
|
+
if (res.status === 403) {
|
|
102
|
+
if (/invalid client/i.test(text)) {
|
|
103
|
+
throw new ApiKeyError('Tesco rejected the x-apikey (403 "Invalid Client"). The public key likely ' +
|
|
104
|
+
"rotated — set the TESCO_API_KEY env var or update PUBLIC_API_KEY.");
|
|
105
|
+
}
|
|
106
|
+
throw new RateLimitedError(403, "Tesco returned 403 (bot-blocked/forbidden) — backing off.");
|
|
107
|
+
}
|
|
108
|
+
if (res.status === 429) {
|
|
109
|
+
throw new RateLimitedError(429, "Tesco returned 429 (rate limited) — backing off.");
|
|
110
|
+
}
|
|
111
|
+
let json;
|
|
112
|
+
try {
|
|
113
|
+
json = JSON.parse(text);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new GraphQLRequestError([
|
|
117
|
+
{ message: `HTTP ${res.status}: non-JSON body`, snippet: text.slice(0, 200) },
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
const first = Array.isArray(json) ? json[0] : json;
|
|
121
|
+
return (first ?? {});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Hand out request slots serially. Each call chains onto the previous one's
|
|
125
|
+
* scheduled slot, so N concurrent callers (e.g. `Promise.all`) are spaced
|
|
126
|
+
* `throttleMs` apart rather than all firing at once.
|
|
127
|
+
*/
|
|
128
|
+
throttle() {
|
|
129
|
+
const slot = this.gate.then(async () => {
|
|
130
|
+
const delay = this.nextAllowedAt - Date.now();
|
|
131
|
+
if (delay > 0)
|
|
132
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
133
|
+
this.nextAllowedAt = Date.now() + this.throttleMs;
|
|
134
|
+
});
|
|
135
|
+
this.gate = slot.catch(() => { }); // one failure must not poison the chain
|
|
136
|
+
return slot;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { keepCookie, sessionFromCookies } from "./auth/harvest.js";
|
|
2
|
+
export type { AuthBackend, Credentials, OtpProvider } from "./auth/types.js";
|
|
3
|
+
export type { Amendment, BasketeerOptions, BasketItemInput } from "./client.js";
|
|
4
|
+
export { Basketeer } from "./client.js";
|
|
5
|
+
export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
|
|
6
|
+
export type { Basket, BasketLine, BookedSlot, MacroFilterKey, Macros, Micronutrient, Nutrition, NutritionBasis, NutritionFilter, NutritionSort, Order, OrderAddress, OrderItem, OrderSlot, PackSize, Price, Product, Promotion, Range, SearchPage, SearchResult, Session, Slot, } from "./models.js";
|
|
7
|
+
export { filterByNutrition, parseNutrition } from "./nutrition.js";
|
|
8
|
+
export type { FulfilmentType, OrderContext } from "./operations.js";
|
|
9
|
+
export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
|
|
10
|
+
export { FileTokenStore } from "./store/file-store.js";
|
|
11
|
+
export { MemoryTokenStore } from "./store/memory-store.js";
|
|
12
|
+
export type { TokenStore } from "./store/types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Public API surface. Everything exported here is part of the semver contract;
|
|
2
|
+
// internal modules (transport, queries, parsers, session-manager) are not.
|
|
3
|
+
export { keepCookie, sessionFromCookies } from "./auth/harvest.js";
|
|
4
|
+
export { Basketeer } from "./client.js";
|
|
5
|
+
// Errors
|
|
6
|
+
export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
|
|
7
|
+
// Nutrition
|
|
8
|
+
export { filterByNutrition, parseNutrition } from "./nutrition.js";
|
|
9
|
+
// Config & helpers
|
|
10
|
+
export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
|
|
11
|
+
// Storage seam
|
|
12
|
+
export { FileTokenStore } from "./store/file-store.js";
|
|
13
|
+
export { MemoryTokenStore } from "./store/memory-store.js";
|
package/dist/jwt.d.ts
ADDED