@webround/commerce 0.0.0-stage → 0.3.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 +192 -2
- package/dist/account/account.d.ts +54 -0
- package/dist/account/account.js +40 -0
- package/dist/account/index.d.ts +3 -0
- package/dist/account/index.js +2 -0
- package/dist/addresses/api.d.ts +16 -0
- package/dist/addresses/api.js +13 -0
- package/dist/addresses/book.d.ts +40 -0
- package/dist/addresses/book.js +144 -0
- package/dist/addresses/index.d.ts +8 -0
- package/dist/addresses/index.js +5 -0
- package/dist/addresses/rules.d.ts +4 -0
- package/dist/addresses/rules.js +9 -0
- package/dist/addresses/types.d.ts +48 -0
- package/dist/addresses/types.js +6 -0
- package/dist/auth/api.d.ts +43 -0
- package/dist/auth/api.js +22 -0
- package/dist/auth/auth.d.ts +32 -0
- package/dist/auth/auth.js +23 -0
- package/dist/auth/cookies.d.ts +46 -0
- package/dist/auth/cookies.js +156 -0
- package/dist/auth/index.d.ts +14 -0
- package/dist/auth/index.js +8 -0
- package/dist/auth/renewal.d.ts +4 -0
- package/dist/auth/renewal.js +37 -0
- package/dist/auth/store.d.ts +70 -0
- package/dist/auth/store.js +284 -0
- package/dist/auth/types.d.ts +105 -0
- package/dist/auth/types.js +5 -0
- package/dist/auth/wake.d.ts +18 -0
- package/dist/auth/wake.js +23 -0
- package/dist/cache.d.ts +49 -0
- package/dist/cache.js +144 -0
- package/dist/canonical.d.ts +7 -0
- package/dist/canonical.js +32 -0
- package/dist/cart/api.d.ts +25 -0
- package/dist/cart/api.js +16 -0
- package/dist/cart/index.d.ts +10 -0
- package/dist/cart/index.js +6 -0
- package/dist/cart/lines.d.ts +15 -0
- package/dist/cart/lines.js +23 -0
- package/dist/cart/storage.d.ts +30 -0
- package/dist/cart/storage.js +82 -0
- package/dist/cart/store.d.ts +58 -0
- package/dist/cart/store.js +218 -0
- package/dist/cart/types.d.ts +194 -0
- package/dist/cart/types.js +11 -0
- package/dist/catalog/cached.d.ts +47 -0
- package/dist/catalog/cached.js +52 -0
- package/dist/catalog/catalog.d.ts +10 -0
- package/dist/catalog/catalog.js +7 -0
- package/dist/catalog/named.d.ts +3 -0
- package/dist/catalog/named.js +7 -0
- package/dist/catalog/products.d.ts +28 -0
- package/dist/catalog/products.js +12 -0
- package/dist/catalog/store-data.d.ts +13 -0
- package/dist/catalog/store-data.js +6 -0
- package/dist/catalog/types.d.ts +430 -0
- package/dist/catalog/types.js +4 -0
- package/dist/checkout/api.d.ts +14 -0
- package/dist/checkout/api.js +7 -0
- package/dist/checkout/errors.d.ts +23 -0
- package/dist/checkout/errors.js +18 -0
- package/dist/checkout/index.d.ts +10 -0
- package/dist/checkout/index.js +6 -0
- package/dist/checkout/return-urls.d.ts +7 -0
- package/dist/checkout/return-urls.js +11 -0
- package/dist/checkout/store.d.ts +98 -0
- package/dist/checkout/store.js +179 -0
- package/dist/checkout/types.d.ts +225 -0
- package/dist/checkout/types.js +28 -0
- package/dist/commerce.d.ts +55 -0
- package/dist/commerce.js +55 -0
- package/dist/customer-resources.d.ts +18 -0
- package/dist/customer-resources.js +40 -0
- package/dist/errors.d.ts +65 -0
- package/dist/errors.js +96 -0
- package/dist/http.d.ts +49 -0
- package/dist/http.js +94 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +22 -0
- package/dist/orders/api.d.ts +16 -0
- package/dist/orders/api.js +13 -0
- package/dist/orders/index.d.ts +6 -0
- package/dist/orders/index.js +3 -0
- package/dist/orders/orders.d.ts +19 -0
- package/dist/orders/orders.js +18 -0
- package/dist/orders/types.d.ts +88 -0
- package/dist/orders/types.js +4 -0
- package/dist/resource.d.ts +19 -0
- package/dist/resource.js +15 -0
- package/dist/returns/api.d.ts +27 -0
- package/dist/returns/api.js +19 -0
- package/dist/returns/index.d.ts +6 -0
- package/dist/returns/index.js +3 -0
- package/dist/returns/returns.d.ts +30 -0
- package/dist/returns/returns.js +31 -0
- package/dist/returns/types.d.ts +99 -0
- package/dist/returns/types.js +2 -0
- package/dist/reviews/api.d.ts +19 -0
- package/dist/reviews/api.js +15 -0
- package/dist/reviews/index.d.ts +9 -0
- package/dist/reviews/index.js +5 -0
- package/dist/reviews/reviews.d.ts +23 -0
- package/dist/reviews/reviews.js +37 -0
- package/dist/reviews/rules.d.ts +8 -0
- package/dist/reviews/rules.js +7 -0
- package/dist/reviews/types.d.ts +54 -0
- package/dist/reviews/types.js +10 -0
- package/dist/shop/address-store.d.ts +14 -0
- package/dist/shop/address-store.js +28 -0
- package/dist/shop/address.d.ts +12 -0
- package/dist/shop/address.js +95 -0
- package/dist/shop/index.d.ts +16 -0
- package/dist/shop/index.js +9 -0
- package/dist/shop/question.d.ts +15 -0
- package/dist/shop/question.js +12 -0
- package/dist/shop/range.d.ts +7 -0
- package/dist/shop/range.js +38 -0
- package/dist/shop/sort.d.ts +19 -0
- package/dist/shop/sort.js +18 -0
- package/dist/shop/state.d.ts +47 -0
- package/dist/shop/state.js +51 -0
- package/dist/shop/store.d.ts +26 -0
- package/dist/shop/store.js +47 -0
- package/dist/shop/to-search.d.ts +14 -0
- package/dist/shop/to-search.js +53 -0
- package/dist/store-kit.d.ts +39 -0
- package/dist/store-kit.js +81 -0
- package/dist/wishlist/api.d.ts +12 -0
- package/dist/wishlist/api.js +10 -0
- package/dist/wishlist/index.d.ts +7 -0
- package/dist/wishlist/index.js +4 -0
- package/dist/wishlist/types.d.ts +30 -0
- package/dist/wishlist/types.js +4 -0
- package/dist/wishlist/wishlist.d.ts +45 -0
- package/dist/wishlist/wishlist.js +84 -0
- package/package.json +39 -5
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { canonicalJson } from '../canonical.js';
|
|
2
|
+
import { CommerceError, isCommerceError } from '../errors.js';
|
|
3
|
+
import { requiresTwoFactor } from './types.js';
|
|
4
|
+
/** How long to wait before asking again after a refresh the store could not be reached for: it doubles each time, up to the last. */
|
|
5
|
+
const RETRY_AFTER_MS = [2000, 4000, 8000, 16000, 30000];
|
|
6
|
+
/** Without a JWT (a page that loaded with no network) there is no end to wait for: it stops after this many asks in all (the wake port asks again when the network is back). */
|
|
7
|
+
const RETRIES_WITHOUT_JWT = 10;
|
|
8
|
+
/** The store did not accept the refresh token (it is not there, does not match, ended, was revoked): the customer is not logged in any more. */
|
|
9
|
+
const isRefusal = (error) => isCommerceError(error) && error.kind === 'http' && (error.status === 401 || error.status === 403);
|
|
10
|
+
export function createAuthStore(options) {
|
|
11
|
+
const { api, storage } = options;
|
|
12
|
+
const now = options.now ?? Date.now;
|
|
13
|
+
const renewBefore = options.renewBeforeMs ?? 60000;
|
|
14
|
+
const timers = options.timers ?? { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (handle) => globalThis.clearTimeout(handle) };
|
|
15
|
+
let state = { status: 'unknown', customer: null, challenge: null, error: null };
|
|
16
|
+
let stateKey = canonicalJson(state);
|
|
17
|
+
let token = null;
|
|
18
|
+
let expiresAt = 0;
|
|
19
|
+
let timer = null;
|
|
20
|
+
// Counts the times the session was made or ended: an answer that comes after that is about a session that is not this one any more (a refresh under way when the customer logs out)
|
|
21
|
+
let epoch = 0;
|
|
22
|
+
// Counts the JWTs the store has been given: two JWTs can be the very same text (the store makes them from the second, the customer and the device), so a new one is told by this and not by its text
|
|
23
|
+
let given = 0;
|
|
24
|
+
// How many times in a row a refresh could not reach the store
|
|
25
|
+
let failures = 0;
|
|
26
|
+
let refreshing = null;
|
|
27
|
+
let restoring = null;
|
|
28
|
+
const listeners = new Set();
|
|
29
|
+
function set(next) {
|
|
30
|
+
const merged = { ...state, ...next };
|
|
31
|
+
const key = canonicalJson(merged);
|
|
32
|
+
if (key === stateKey)
|
|
33
|
+
return;
|
|
34
|
+
state = merged;
|
|
35
|
+
stateKey = key;
|
|
36
|
+
// A listener that throws changes nothing here: it is thrown where nobody is waiting on it, so it is seen
|
|
37
|
+
for (const listener of [...listeners]) {
|
|
38
|
+
try {
|
|
39
|
+
listener();
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
setTimeout(() => { throw error; }, 0);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function stopTimer() {
|
|
47
|
+
if (timer !== null)
|
|
48
|
+
timers.clearTimeout(timer);
|
|
49
|
+
timer = null;
|
|
50
|
+
}
|
|
51
|
+
/** The JWT is renewed by itself a little before it ends, so a page that stays open stays logged in. */
|
|
52
|
+
function schedule() {
|
|
53
|
+
stopTimer();
|
|
54
|
+
if (token === null)
|
|
55
|
+
return;
|
|
56
|
+
const handle = timers.setTimeout(() => { timer = null; void refresh(); }, Math.max(1000, expiresAt - now() - renewBefore));
|
|
57
|
+
timer = handle;
|
|
58
|
+
handle?.unref?.(); // a timer does not keep a script alive
|
|
59
|
+
}
|
|
60
|
+
/** A refresh that did not reach the store is asked again, sooner and sooner than the JWT ends, for as long as that JWT is good: a customer whose network is down for a minute is still logged in when it is back. */
|
|
61
|
+
function retry() {
|
|
62
|
+
const holding = token !== null;
|
|
63
|
+
if (holding ? now() >= expiresAt : state.status !== 'unknown' || failures >= RETRIES_WITHOUT_JWT)
|
|
64
|
+
return;
|
|
65
|
+
stopTimer();
|
|
66
|
+
const wait = RETRY_AFTER_MS[Math.min(failures - 1, RETRY_AFTER_MS.length - 1)] ?? 30000;
|
|
67
|
+
const handle = timers.setTimeout(() => {
|
|
68
|
+
timer = null;
|
|
69
|
+
if (state.status === 'unknown')
|
|
70
|
+
void restore();
|
|
71
|
+
else
|
|
72
|
+
void refresh();
|
|
73
|
+
}, holding ? Math.min(wait, Math.max(1000, expiresAt - now())) : wait);
|
|
74
|
+
timer = handle;
|
|
75
|
+
handle?.unref?.();
|
|
76
|
+
}
|
|
77
|
+
function takeCustomer(session) {
|
|
78
|
+
const { token: jwt, expiresIn, ...rest } = session;
|
|
79
|
+
token = jwt;
|
|
80
|
+
failures = 0;
|
|
81
|
+
given++;
|
|
82
|
+
expiresAt = now() + expiresIn * 1000;
|
|
83
|
+
const { csrfToken: _csrf, extraData: _extra, ...customer } = rest;
|
|
84
|
+
return customer;
|
|
85
|
+
}
|
|
86
|
+
function accept(session) {
|
|
87
|
+
epoch++;
|
|
88
|
+
const customer = takeCustomer(session);
|
|
89
|
+
storage.saveCsrfToken(session.csrfToken);
|
|
90
|
+
set({ status: 'signedIn', customer, challenge: null, error: null });
|
|
91
|
+
schedule();
|
|
92
|
+
}
|
|
93
|
+
function endSession() {
|
|
94
|
+
epoch++;
|
|
95
|
+
stopTimer();
|
|
96
|
+
token = null;
|
|
97
|
+
failures = 0;
|
|
98
|
+
expiresAt = 0;
|
|
99
|
+
storage.forgetCsrfToken();
|
|
100
|
+
set({ status: 'signedOut', customer: null, challenge: null, error: null });
|
|
101
|
+
}
|
|
102
|
+
function refresh() {
|
|
103
|
+
if (refreshing)
|
|
104
|
+
return refreshing;
|
|
105
|
+
const csrfToken = storage.csrfToken();
|
|
106
|
+
if (csrfToken === null) {
|
|
107
|
+
// Nothing to ask with: whoever was logged in is not any more
|
|
108
|
+
if (state.status !== 'signingIn' && state.status !== 'challenge')
|
|
109
|
+
endSession();
|
|
110
|
+
return Promise.resolve(false);
|
|
111
|
+
}
|
|
112
|
+
const mine = epoch;
|
|
113
|
+
const asked = (async () => {
|
|
114
|
+
// `refreshing` is set before anything is asked, whatever the api does with the call
|
|
115
|
+
await Promise.resolve();
|
|
116
|
+
try {
|
|
117
|
+
const session = await api.refresh({ deviceId: storage.deviceId() }, { csrfToken });
|
|
118
|
+
if (epoch !== mine)
|
|
119
|
+
return false;
|
|
120
|
+
const customer = takeCustomer(session);
|
|
121
|
+
set({ status: 'signedIn', customer, challenge: null, error: null });
|
|
122
|
+
schedule();
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (epoch !== mine)
|
|
127
|
+
return false;
|
|
128
|
+
if (isRefusal(error)) {
|
|
129
|
+
endSession();
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
// The store was not reached (or was in trouble): nothing is ended. What was good stays, and the next try may work
|
|
133
|
+
set({ error: isCommerceError(error) ? error : new CommerceError({ kind: 'network', message: 'The store could not be reached.', cause: error }) });
|
|
134
|
+
failures++;
|
|
135
|
+
retry();
|
|
136
|
+
return token !== null;
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
refreshing = asked;
|
|
140
|
+
const clear = () => { if (refreshing === asked)
|
|
141
|
+
refreshing = null; };
|
|
142
|
+
asked.then(clear, clear);
|
|
143
|
+
return asked;
|
|
144
|
+
}
|
|
145
|
+
function restore() {
|
|
146
|
+
if (state.status !== 'unknown' && restoring === null)
|
|
147
|
+
return Promise.resolve();
|
|
148
|
+
if (restoring)
|
|
149
|
+
return restoring;
|
|
150
|
+
const done = (async () => {
|
|
151
|
+
await Promise.resolve();
|
|
152
|
+
if (storage.csrfToken() === null) {
|
|
153
|
+
endSession();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
await refresh();
|
|
157
|
+
// Not refused, not reached: it is still not known
|
|
158
|
+
})();
|
|
159
|
+
restoring = done;
|
|
160
|
+
const clear = () => { if (restoring === done)
|
|
161
|
+
restoring = null; };
|
|
162
|
+
done.then(clear, clear);
|
|
163
|
+
return done;
|
|
164
|
+
}
|
|
165
|
+
function deviceFields() {
|
|
166
|
+
return { deviceId: storage.deviceId(), ...(options.device?.platform ? { platform: options.device.platform } : {}), ...(options.device?.region ? { region: options.device.region } : {}) };
|
|
167
|
+
}
|
|
168
|
+
/** What an action does while its call is under way, and what it leaves if it fails. */
|
|
169
|
+
async function acting(call) {
|
|
170
|
+
const before = state;
|
|
171
|
+
set({ status: 'signingIn', error: null });
|
|
172
|
+
try {
|
|
173
|
+
return await call();
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
set({ status: before.status === 'signingIn' ? 'signedOut' : before.status, error: null });
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function accessToken() {
|
|
181
|
+
if (state.status === 'unknown')
|
|
182
|
+
await restore();
|
|
183
|
+
if (token === null)
|
|
184
|
+
return null;
|
|
185
|
+
if (expiresAt - now() > renewBefore)
|
|
186
|
+
return token;
|
|
187
|
+
await refresh();
|
|
188
|
+
return token;
|
|
189
|
+
}
|
|
190
|
+
async function renew(rejected) {
|
|
191
|
+
if (token !== null && token !== rejected)
|
|
192
|
+
return token;
|
|
193
|
+
if (token === null)
|
|
194
|
+
return null;
|
|
195
|
+
// A renewal that gave no JWT (the store was not reached, or it was refused) is not a JWT to ask again with
|
|
196
|
+
const before = given;
|
|
197
|
+
await refresh();
|
|
198
|
+
return token !== null && given !== before ? token : null;
|
|
199
|
+
}
|
|
200
|
+
async function authHeaders() {
|
|
201
|
+
const jwt = await accessToken();
|
|
202
|
+
return jwt === null ? {} : { Authorization: `Bearer ${jwt}` };
|
|
203
|
+
}
|
|
204
|
+
/** The page is back. A timer did not run while the machine slept, or the page was hidden: the JWT is looked at against the clock, and the next renewal is set from it. */
|
|
205
|
+
function woke() {
|
|
206
|
+
if (disposed)
|
|
207
|
+
return;
|
|
208
|
+
if (state.status === 'unknown') {
|
|
209
|
+
void restore();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (state.status !== 'signedIn' || token === null)
|
|
213
|
+
return;
|
|
214
|
+
if (expiresAt - now() <= renewBefore)
|
|
215
|
+
void refresh();
|
|
216
|
+
else
|
|
217
|
+
schedule();
|
|
218
|
+
}
|
|
219
|
+
let disposed = false;
|
|
220
|
+
const stopWaking = options.wake?.subscribe(woke);
|
|
221
|
+
return {
|
|
222
|
+
getState: () => state,
|
|
223
|
+
dispose() {
|
|
224
|
+
disposed = true;
|
|
225
|
+
stopTimer();
|
|
226
|
+
stopWaking?.();
|
|
227
|
+
},
|
|
228
|
+
subscribe(listener) {
|
|
229
|
+
listeners.add(listener);
|
|
230
|
+
return () => { listeners.delete(listener); };
|
|
231
|
+
},
|
|
232
|
+
restore,
|
|
233
|
+
refresh,
|
|
234
|
+
accept,
|
|
235
|
+
signup: (input, callOptions) => acting(async () => { accept(await api.signup({ ...input, ...deviceFields() }, callOptions)); }),
|
|
236
|
+
login: (input) => acting(async () => {
|
|
237
|
+
const answer = await api.login({ ...input, ...deviceFields() });
|
|
238
|
+
if (requiresTwoFactor(answer))
|
|
239
|
+
set({ status: 'challenge', challenge: answer, customer: null, error: null });
|
|
240
|
+
else
|
|
241
|
+
accept(answer);
|
|
242
|
+
}),
|
|
243
|
+
async verifyTwoFactor(totpCode) {
|
|
244
|
+
const challenge = state.challenge;
|
|
245
|
+
if (!challenge)
|
|
246
|
+
throw new CommerceError({ kind: 'http', message: 'There is no login waiting for a code.', status: null });
|
|
247
|
+
const before = state;
|
|
248
|
+
set({ status: 'signingIn' });
|
|
249
|
+
try {
|
|
250
|
+
accept(await api.totpLoginVerify({ challengeId: challenge.challengeId, totpCode, ...deviceFields() }));
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
// The challenge ended (or is not there): there is nothing to answer any more. A wrong code leaves it, to try again
|
|
254
|
+
if (isCommerceError(error) && error.code === 'TWO_FA_CHALLENGE_NOT_FOUND')
|
|
255
|
+
endSession();
|
|
256
|
+
else
|
|
257
|
+
set({ status: before.status, challenge });
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
cancelChallenge() {
|
|
262
|
+
if (state.status === 'challenge')
|
|
263
|
+
set({ status: 'signedOut', challenge: null });
|
|
264
|
+
},
|
|
265
|
+
async logout() {
|
|
266
|
+
if (state.status === 'unknown')
|
|
267
|
+
await restore();
|
|
268
|
+
if (token !== null) {
|
|
269
|
+
try {
|
|
270
|
+
// The JWT goes in the header; the store ends the refresh token of this device and clears its cookie
|
|
271
|
+
await api.logout({ deviceId: storage.deviceId() }, { accessToken: token });
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// The store was not reached: the session ends here all the same (without the CSRF token a refresh cannot be asked for)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
endSession();
|
|
278
|
+
},
|
|
279
|
+
accessToken,
|
|
280
|
+
renew,
|
|
281
|
+
authHeaders,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** What the customer's device says about itself: `deviceId` (made by the client, 5 characters at least) is what the refresh token belongs to. */
|
|
2
|
+
export interface DeviceInput {
|
|
3
|
+
deviceId: string;
|
|
4
|
+
/** `web`, `ios`, `android`… */
|
|
5
|
+
platform?: string;
|
|
6
|
+
/** `IT`, `FR`… */
|
|
7
|
+
region?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SignupInput extends DeviceInput {
|
|
10
|
+
email: string;
|
|
11
|
+
/** 8 to 128 characters, with an uppercase letter, a lowercase one, a number and a special character. */
|
|
12
|
+
password: string;
|
|
13
|
+
/** The same as `password`. */
|
|
14
|
+
confirmPassword: string;
|
|
15
|
+
/** 2 to 64 characters. */
|
|
16
|
+
displayName: string;
|
|
17
|
+
phone?: string | null;
|
|
18
|
+
newsletter?: boolean | null;
|
|
19
|
+
}
|
|
20
|
+
export interface LoginInput extends DeviceInput {
|
|
21
|
+
email: string;
|
|
22
|
+
password: string;
|
|
23
|
+
}
|
|
24
|
+
/** The second step of a login whose customer has two-factor authentication on: the code of their authenticator app, for the challenge the login answered. */
|
|
25
|
+
export interface TotpLoginVerifyInput extends DeviceInput {
|
|
26
|
+
challengeId: string;
|
|
27
|
+
/** The six digits. */
|
|
28
|
+
totpCode: string;
|
|
29
|
+
}
|
|
30
|
+
/** Turns two-factor authentication on, after `totpSetup`: the code the app shows for the secret. */
|
|
31
|
+
export interface TotpConfirmInput extends DeviceInput {
|
|
32
|
+
totpCode: string;
|
|
33
|
+
}
|
|
34
|
+
export interface RefreshInput {
|
|
35
|
+
deviceId: string;
|
|
36
|
+
}
|
|
37
|
+
export interface LogoutInput {
|
|
38
|
+
deviceId: string;
|
|
39
|
+
}
|
|
40
|
+
export interface RequestResetInput {
|
|
41
|
+
email: string;
|
|
42
|
+
}
|
|
43
|
+
/** The address a newsletter subscription or cancellation is about. A logged-in customer's own address is used by the store whatever is sent (it is still required). */
|
|
44
|
+
export interface NewsletterInput {
|
|
45
|
+
email: string;
|
|
46
|
+
}
|
|
47
|
+
/** What the calls that make the store send an email can say: the language it is written in (the `Accept-Language` of the request). */
|
|
48
|
+
export interface EmailOptions {
|
|
49
|
+
language?: string;
|
|
50
|
+
}
|
|
51
|
+
/** The customer and their session, as signup, login and the second step of a login answer. */
|
|
52
|
+
export interface AuthSession {
|
|
53
|
+
customerId: string;
|
|
54
|
+
email: string;
|
|
55
|
+
displayName: string | null;
|
|
56
|
+
emailVerified: boolean;
|
|
57
|
+
provider: string;
|
|
58
|
+
/** When the customer was made, as a date in text (ISO). */
|
|
59
|
+
createdAt: string | null;
|
|
60
|
+
twoFactorEnabled: boolean;
|
|
61
|
+
vatExempt: boolean;
|
|
62
|
+
vatCode: string;
|
|
63
|
+
newsletter: boolean;
|
|
64
|
+
/** What the store keeps about the customer: `null` from a login, `{}` from a signup and a refresh. */
|
|
65
|
+
metadata: unknown;
|
|
66
|
+
/** The JWT that says who the customer is (`Authorization: Bearer …`): it lasts `expiresIn` seconds (an hour). */
|
|
67
|
+
token: string;
|
|
68
|
+
/** Made with the refresh token (the cookie `wrRefreshToken_<storeId>`) and good for as long as it is: a refresh asks for it (`x-wr-csrf`), and does not give a new one. */
|
|
69
|
+
csrfToken: string;
|
|
70
|
+
expiresIn: number;
|
|
71
|
+
/** What a call adds (the recovery codes of a confirmed two-factor setup, for one). */
|
|
72
|
+
extraData?: unknown;
|
|
73
|
+
}
|
|
74
|
+
/** What a refresh answers: the session again, with a new JWT, and no `csrfToken` (the one the login gave still holds). */
|
|
75
|
+
export type RefreshedSession = Omit<AuthSession, 'csrfToken' | 'extraData'>;
|
|
76
|
+
/** A login of a customer with two-factor authentication on is not a session yet: the code of their app is asked for (`totpLoginVerify`), for this `challengeId`. */
|
|
77
|
+
export interface TwoFactorChallenge {
|
|
78
|
+
requires2FA: true;
|
|
79
|
+
challengeId: string;
|
|
80
|
+
customerId: string;
|
|
81
|
+
email: string;
|
|
82
|
+
deviceId: string;
|
|
83
|
+
}
|
|
84
|
+
export type LoginAnswer = AuthSession | TwoFactorChallenge;
|
|
85
|
+
/** Whether a login answered a challenge (two-factor) and not a session. */
|
|
86
|
+
export declare const requiresTwoFactor: (answer: LoginAnswer) => answer is TwoFactorChallenge;
|
|
87
|
+
/**
|
|
88
|
+
* What subscribing to the newsletter or cancelling answers. `direct: true`: it is done. `direct: false`: the store sent an email with a link to confirm (a guest, or a customer whose address is not verified).
|
|
89
|
+
* A logged-in customer who is already in the state asked for gets `direct: false` and no email: that is for the screen to know (`customer.newsletter`).
|
|
90
|
+
*/
|
|
91
|
+
export interface NewsletterAnswer {
|
|
92
|
+
direct: boolean;
|
|
93
|
+
}
|
|
94
|
+
/** What `totpConfirm` answers: the session again (two-factor is now on), with the recovery codes in `extraData.recoveryCodes`: the only time they are given. */
|
|
95
|
+
export type TotpConfirmAnswer = AuthSession & {
|
|
96
|
+
extraData?: {
|
|
97
|
+
recoveryCodes?: string[];
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
/** What a customer adds to their authenticator app. */
|
|
101
|
+
export interface TotpSetup {
|
|
102
|
+
secret: string;
|
|
103
|
+
otpauthUrl: string;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// What commerce-core takes and answers for a store's customers (`/store-customers/auth/*`), written from what a real store answered (smoke/auth.smoke.ts), not from its OpenAPI or its DTOs: the DTO
|
|
2
|
+
// of the session leaves out `csrfToken`, which the real answer has. Nothing here is a default of the server's: what is not said is for the server to decide.
|
|
3
|
+
/** Whether a login answered a challenge (two-factor) and not a session. */
|
|
4
|
+
export const requiresTwoFactor = (answer) => 'requires2FA' in answer && answer.requires2FA === true;
|
|
5
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface WakePort {
|
|
2
|
+
/** Calls `listener` each time the page is back; the function it returns stops that. */
|
|
3
|
+
subscribe(listener: () => void): () => void;
|
|
4
|
+
}
|
|
5
|
+
interface Target {
|
|
6
|
+
addEventListener(type: string, listener: () => void): void;
|
|
7
|
+
removeEventListener(type: string, listener: () => void): void;
|
|
8
|
+
}
|
|
9
|
+
interface Env {
|
|
10
|
+
window?: Target;
|
|
11
|
+
document?: Target & {
|
|
12
|
+
visibilityState?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** The browser's: the tab is visible again, the network is back, or the page is shown again from the back-forward cache. Where there is no window (a server render, a worker) it never says anything. */
|
|
16
|
+
export declare function browserWakePort(env?: Env): WakePort;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=wake.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// "The page is back", as a port: a timer does not run while a machine sleeps or a tab is frozen, so a session kept good by a timer has to be looked at when the page comes back. The store only knows this one
|
|
2
|
+
// function; the browser, a test and a runtime with no window are all the same to it.
|
|
3
|
+
/** The browser's: the tab is visible again, the network is back, or the page is shown again from the back-forward cache. Where there is no window (a server render, a worker) it never says anything. */
|
|
4
|
+
export function browserWakePort(env = globalThis) {
|
|
5
|
+
return {
|
|
6
|
+
subscribe(listener) {
|
|
7
|
+
const { window: win, document: doc } = env;
|
|
8
|
+
if (!win || !doc)
|
|
9
|
+
return () => { };
|
|
10
|
+
const onVisibility = () => { if (doc.visibilityState === 'visible')
|
|
11
|
+
listener(); };
|
|
12
|
+
doc.addEventListener('visibilitychange', onVisibility);
|
|
13
|
+
win.addEventListener('online', listener);
|
|
14
|
+
win.addEventListener('pageshow', listener);
|
|
15
|
+
return () => {
|
|
16
|
+
doc.removeEventListener('visibilitychange', onVisibility);
|
|
17
|
+
win.removeEventListener('online', listener);
|
|
18
|
+
win.removeEventListener('pageshow', listener);
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=wake.js.map
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type CacheSnapshot<T = unknown> = {
|
|
2
|
+
status: 'idle';
|
|
3
|
+
} | {
|
|
4
|
+
status: 'pending';
|
|
5
|
+
} | {
|
|
6
|
+
status: 'ready';
|
|
7
|
+
data: T;
|
|
8
|
+
} | {
|
|
9
|
+
status: 'error';
|
|
10
|
+
error: unknown;
|
|
11
|
+
};
|
|
12
|
+
export interface LoadOptions {
|
|
13
|
+
/** To give up on THIS call. Others waiting for the same key are not affected. */
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
export interface Cache {
|
|
17
|
+
/**
|
|
18
|
+
* The answer for a key. `ready`: it is given back, `fetcher` is not called. `pending`: the request under way is joined. `idle` or `error`: `fetcher` is called, once, and the key is
|
|
19
|
+
* `pending` until it ends. `fetcher` is given a signal that is aborted when nobody is waiting any more.
|
|
20
|
+
*/
|
|
21
|
+
load<T>(key: string, fetcher: (signal: AbortSignal) => Promise<T>, options?: LoadOptions): Promise<T>;
|
|
22
|
+
/** The state of a key now. It never starts anything. It is the SAME object until the key changes (so a framework can tell that something did). */
|
|
23
|
+
snapshot<T = unknown>(key: string): CacheSnapshot<T>;
|
|
24
|
+
/** Called whenever the state of this key changes, once for each change. Returns what stops it. */
|
|
25
|
+
subscribe(key: string, listener: () => void): () => void;
|
|
26
|
+
/**
|
|
27
|
+
* Forgets the keys that start with `prefix` (all of them with an empty one). A request under way for one of them goes on for whoever waits for it, but what it brings is not kept:
|
|
28
|
+
* the next `load` asks again.
|
|
29
|
+
*/
|
|
30
|
+
invalidate(prefix: string): void;
|
|
31
|
+
}
|
|
32
|
+
export declare function createCache(): Cache;
|
|
33
|
+
/** The parts a key is made of: which backend, which store, which call, what it is about (an id, a slug) and what was asked. */
|
|
34
|
+
export interface CacheKeyParts {
|
|
35
|
+
/** The backend's address. */
|
|
36
|
+
base: string;
|
|
37
|
+
storeId: string;
|
|
38
|
+
/** `products.search`, `products.getReviews`… */
|
|
39
|
+
call: string;
|
|
40
|
+
/** What the call is about, when it is about one thing: a product's id, a variant's slug. */
|
|
41
|
+
subject?: string;
|
|
42
|
+
/** What was asked, as the call takes it: it is written in its canonical form, so the same question is the same key however it was written. */
|
|
43
|
+
params?: unknown;
|
|
44
|
+
}
|
|
45
|
+
/** One key for one question. The parts before the params are written so that none holds the separator: a prefix of them (`cachePrefix`) can only match what it names. */
|
|
46
|
+
export declare function cacheKey(parts: CacheKeyParts): string;
|
|
47
|
+
/** The start of the keys of a store, of one call, or of one call about one subject: what `invalidate` takes. It ends with the separator, so `a` does not match `ab`. */
|
|
48
|
+
export declare function cachePrefix(parts: Pick<CacheKeyParts, 'base' | 'storeId'> & Partial<Pick<CacheKeyParts, 'call' | 'subject'>>): string;
|
|
49
|
+
//# sourceMappingURL=cache.d.ts.map
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { canonicalJson } from './canonical.js';
|
|
2
|
+
import { CommerceError } from './errors.js';
|
|
3
|
+
const IDLE = { status: 'idle' };
|
|
4
|
+
const aborted = () => new CommerceError({ kind: 'aborted', message: 'The request was cancelled.' });
|
|
5
|
+
export function createCache() {
|
|
6
|
+
const states = new Map();
|
|
7
|
+
const flights = new Map();
|
|
8
|
+
const listeners = new Map();
|
|
9
|
+
function notify(key) {
|
|
10
|
+
const set = listeners.get(key);
|
|
11
|
+
if (!set)
|
|
12
|
+
return;
|
|
13
|
+
for (const listener of [...set]) {
|
|
14
|
+
// A listener that throws must change nothing here: not what the others are told, not what whoever waits is given. It is thrown where nobody is waiting on it, so it is seen
|
|
15
|
+
try {
|
|
16
|
+
listener();
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
setTimeout(() => { throw error; }, 0);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function setState(key, state) {
|
|
24
|
+
if (state === undefined)
|
|
25
|
+
states.delete(key);
|
|
26
|
+
else
|
|
27
|
+
states.set(key, state);
|
|
28
|
+
notify(key);
|
|
29
|
+
}
|
|
30
|
+
/** One caller less is waiting. When none is left, the request is given up and the key is as if it had never been asked. */
|
|
31
|
+
function release(flight) {
|
|
32
|
+
flight.waiters--;
|
|
33
|
+
if (flight.waiters > 0)
|
|
34
|
+
return;
|
|
35
|
+
flight.controller.abort();
|
|
36
|
+
if (flights.get(flight.key) === flight) {
|
|
37
|
+
flights.delete(flight.key);
|
|
38
|
+
setState(flight.key, undefined);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function start(key, fetcher) {
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const flight = { key, controller, promise: Promise.resolve(), waiters: 0 };
|
|
44
|
+
flights.set(key, flight);
|
|
45
|
+
let asked;
|
|
46
|
+
try {
|
|
47
|
+
asked = fetcher(controller.signal);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
asked = Promise.reject(error);
|
|
51
|
+
}
|
|
52
|
+
flight.promise = asked.then((data) => {
|
|
53
|
+
// Still the request this key is waiting for (it was not given up, nor forgotten while it was under way): what it brought is kept
|
|
54
|
+
if (flights.get(key) === flight) {
|
|
55
|
+
flights.delete(key);
|
|
56
|
+
setState(key, { status: 'ready', data });
|
|
57
|
+
}
|
|
58
|
+
return data;
|
|
59
|
+
}, (error) => {
|
|
60
|
+
if (flights.get(key) === flight) {
|
|
61
|
+
flights.delete(key);
|
|
62
|
+
setState(key, { status: 'error', error });
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
});
|
|
66
|
+
// Whoever waits is told of the failure; if nobody is (they all gave up), it is not an unhandled one
|
|
67
|
+
flight.promise.catch(() => undefined);
|
|
68
|
+
setState(key, { status: 'pending' });
|
|
69
|
+
return flight;
|
|
70
|
+
}
|
|
71
|
+
function join(flight, signal) {
|
|
72
|
+
flight.waiters++;
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
let over = false;
|
|
75
|
+
const finish = () => {
|
|
76
|
+
if (over)
|
|
77
|
+
return false;
|
|
78
|
+
over = true;
|
|
79
|
+
signal?.removeEventListener('abort', onAbort);
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
82
|
+
const onAbort = () => {
|
|
83
|
+
if (!finish())
|
|
84
|
+
return;
|
|
85
|
+
reject(aborted());
|
|
86
|
+
release(flight);
|
|
87
|
+
};
|
|
88
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
89
|
+
flight.promise.then((data) => { if (finish())
|
|
90
|
+
resolve(data); }, (error) => { if (finish())
|
|
91
|
+
reject(error); });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
load(key, fetcher, options) {
|
|
96
|
+
// A call that was given up before it began asks nothing, and is told nothing
|
|
97
|
+
if (options?.signal?.aborted)
|
|
98
|
+
return Promise.reject(aborted());
|
|
99
|
+
const state = states.get(key);
|
|
100
|
+
if (state?.status === 'ready')
|
|
101
|
+
return Promise.resolve(state.data);
|
|
102
|
+
const flight = flights.get(key) ?? start(key, fetcher);
|
|
103
|
+
return join(flight, options?.signal);
|
|
104
|
+
},
|
|
105
|
+
snapshot(key) {
|
|
106
|
+
return (states.get(key) ?? IDLE);
|
|
107
|
+
},
|
|
108
|
+
subscribe(key, listener) {
|
|
109
|
+
let set = listeners.get(key);
|
|
110
|
+
if (!set)
|
|
111
|
+
listeners.set(key, (set = new Set()));
|
|
112
|
+
set.add(listener);
|
|
113
|
+
return () => {
|
|
114
|
+
set.delete(listener);
|
|
115
|
+
if (set.size === 0 && listeners.get(key) === set)
|
|
116
|
+
listeners.delete(key);
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
invalidate(prefix) {
|
|
120
|
+
const keys = [...new Set([...states.keys(), ...flights.keys()])].filter((key) => key.startsWith(prefix));
|
|
121
|
+
// First everything is forgotten, then those who watch are told: one that reads in its listener finds the cache whole
|
|
122
|
+
for (const key of keys) {
|
|
123
|
+
flights.delete(key); // the request goes on for whoever waits for it; what it brings is no longer this key's
|
|
124
|
+
states.delete(key);
|
|
125
|
+
}
|
|
126
|
+
for (const key of keys)
|
|
127
|
+
notify(key);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const part = (value) => encodeURIComponent(value);
|
|
132
|
+
const baseOf = (base) => base.replace(/\/+$/, '');
|
|
133
|
+
/** One key for one question. The parts before the params are written so that none holds the separator: a prefix of them (`cachePrefix`) can only match what it names. */
|
|
134
|
+
export function cacheKey(parts) {
|
|
135
|
+
return `${cachePrefix(parts)}${parts.params === undefined ? '' : canonicalJson(parts.params)}`;
|
|
136
|
+
}
|
|
137
|
+
/** The start of the keys of a store, of one call, or of one call about one subject: what `invalidate` takes. It ends with the separator, so `a` does not match `ab`. */
|
|
138
|
+
export function cachePrefix(parts) {
|
|
139
|
+
const store = `${part(baseOf(parts.base))}|${part(parts.storeId)}|`;
|
|
140
|
+
if (parts.call === undefined)
|
|
141
|
+
return store;
|
|
142
|
+
return `${store}${part(parts.call)}|${parts.subject === undefined ? '' : `${part(parts.subject)}|`}`;
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=cache.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type Canonical = string | number | boolean | null | Canonical[] | {
|
|
2
|
+
[key: string]: Canonical;
|
|
3
|
+
};
|
|
4
|
+
export declare function canonicalize(value: unknown): Canonical | undefined;
|
|
5
|
+
/** The canonical text of a value: what goes in the body of a request, and what a cache key is made of. */
|
|
6
|
+
export declare function canonicalJson(value: unknown): string;
|
|
7
|
+
//# sourceMappingURL=canonical.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The same data, written the one way. Two bodies that mean the same thing (`{ a: 1, b: [2, 1] }` and `{ b: [1, 2], a: 1 }`) come out as the same text, so a request is always sent the same way and,
|
|
2
|
+
// one layer up, the same request is the same key. The rules:
|
|
3
|
+
// - keys are in order, at every depth (by code point: the same everywhere);
|
|
4
|
+
// - the items of a list are in order too (by their own canonical text). An API for which the order of a list matters is the API's mistake, not something to carry here;
|
|
5
|
+
// - what is `undefined` is not there; `null` is a value and stays; an empty list stays (it is data: the server's default for "nothing" is not this package's to know);
|
|
6
|
+
// - what is sent is JSON: numbers, texts, booleans, null, lists and plain objects. Nothing else is looked after (a Date, a Map).
|
|
7
|
+
// The input is never changed.
|
|
8
|
+
export function canonicalize(value) {
|
|
9
|
+
if (value === undefined)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (value === null || typeof value !== 'object')
|
|
12
|
+
return value;
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
return value
|
|
15
|
+
.map((item) => canonicalize(item) ?? null)
|
|
16
|
+
.sort((a, b) => compare(JSON.stringify(a), JSON.stringify(b)));
|
|
17
|
+
}
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const key of Object.keys(value).sort(compare)) {
|
|
20
|
+
const item = canonicalize(value[key]);
|
|
21
|
+
if (item !== undefined)
|
|
22
|
+
out[key] = item;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/** The canonical text of a value: what goes in the body of a request, and what a cache key is made of. */
|
|
27
|
+
export function canonicalJson(value) {
|
|
28
|
+
return JSON.stringify(canonicalize(value) ?? null);
|
|
29
|
+
}
|
|
30
|
+
// By code point, not by the language's collation: `Array.prototype.sort()` with no function is by UTF-16 unit, which is what `<` is too, so this is the same rule written down.
|
|
31
|
+
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
32
|
+
//# sourceMappingURL=canonical.js.map
|