@faststore/api 1.12.50 → 1.12.52

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.
@@ -14,6 +14,8 @@ import { MasterDataResponse } from './types/Newsletter';
14
14
  import type { Address, AddressInput } from './types/Address';
15
15
  import { ShippingDataBody } from './types/ShippingData';
16
16
  declare type ValueOf<T> = T extends Record<string, infer K> ? K : never;
17
+ /** Test-only: forget which accounts were warned about the anonymous fallback. */
18
+ export declare const resetAnonymousNewsletterWarnings: () => void;
17
19
  export declare const VtexCommerce: ({ account, environment }: Options, ctx: Context) => {
18
20
  catalog: {
19
21
  salesChannel: (sc: string) => Promise<SalesChannel>;
@@ -62,8 +64,20 @@ export declare const VtexCommerce: ({ account, environment }: Options, ctx: Cont
62
64
  address: ({ postalCode, country, }: AddressInput) => Promise<Address>;
63
65
  };
64
66
  session: (search: string) => Promise<Session>;
65
- subscribeToNewsletter: (data: {
66
- name: string;
67
+ /**
68
+ * Creates a newsletter subscription in the `NL` Master Data entity.
69
+ *
70
+ * With `FS_NEWSLETTER_APP_KEY`/`FS_NEWSLETTER_APP_TOKEN` set, the document is
71
+ * created with a `POST` authenticated by that dedicated key, so `NL` fields
72
+ * can stay private. The request is create-only: the body is built here from
73
+ * the validated fields and never carries a document id, so the caller can't
74
+ * reach an existing record.
75
+ *
76
+ * Without the credentials, it falls back to the legacy anonymous `PATCH`,
77
+ * which only works while `NL` fields are public.
78
+ */
79
+ subscribeToNewsletter: ({ name, email, }: {
80
+ name?: string | undefined;
67
81
  email: string;
68
82
  }) => Promise<MasterDataResponse>;
69
83
  };
@@ -1 +1,6 @@
1
- export declare const fetchAPI: (info: RequestInfo, init?: RequestInit | undefined) => Promise<any>;
1
+ export declare const redactHeaders: (headers?: Headers | string[][] | Record<string, string> | undefined) => Record<string, string> | undefined;
2
+ interface FetchAPIOptions {
3
+ storeCookies?: (headers: Headers) => void;
4
+ }
5
+ export declare const fetchAPI: (info: RequestInfo, init?: RequestInit | undefined, options?: FetchAPIOptions | undefined) => Promise<any>;
6
+ export {};
@@ -28,6 +28,11 @@ export interface Context {
28
28
  locale: string;
29
29
  flags: FeatureFlags;
30
30
  searchArgs?: Omit<SearchArgs, 'type'>;
31
+ /**
32
+ * Checkout cookies received while resolving this request, keyed by cookie
33
+ * name. The GraphQL handler forwards `setCookie` values to the browser.
34
+ */
35
+ cookies: Map<string, Record<string, string>>;
31
36
  };
32
37
  headers: Record<string, string>;
33
38
  }
@@ -0,0 +1,52 @@
1
+ import type { Context } from '../index';
2
+ export interface ContextForCookies {
3
+ headers: Context['headers'];
4
+ storage: Pick<Context['storage'], 'cookies'>;
5
+ }
6
+ /**
7
+ * Returns every `set-cookie` value of a fetch response.
8
+ *
9
+ * `Headers.getSetCookie()` only exists on Node >= 18.14 (undici). FastStore v1
10
+ * stores may still run on Node 16, where isomorphic-unfetch falls back to
11
+ * node-fetch v2, which exposes repeated headers through `raw()` instead.
12
+ */
13
+ export declare const getSetCookies: (headers: Headers) => string[];
14
+ /**
15
+ * This function updates the ctx.storage.cookies, that is used in each request.
16
+ *
17
+ * ctx.storage.cookies is a Map<string[1], Record<string, string>[2]> where,
18
+ * [1] cookie key
19
+ * [2] {
20
+ * value: cookie value,
21
+ * setCookie: setCookie used in browser response
22
+ * }
23
+ */
24
+ export declare const updatesContextStorageCookies: (ctx: Pick<Context, 'storage'>, setCookieValue: string) => void;
25
+ export declare const setCookie: (ctx: Pick<Context, 'storage'>, headers: Headers) => void;
26
+ export declare const getStoreCookie: (ctx: Pick<Context, 'storage'>) => (headers: Headers) => void;
27
+ /**
28
+ * This function returns a modified copy of the original cookie header (ctx.headers.cookie from the first request)
29
+ * with the cookie values that comes in each request (ctx.storage.cookies).
30
+ * If there is no cookies in storage, the ctx.headers?.cookie is used
31
+ *
32
+ * ctx.storage.cookies is a Map<string[1], Record<string, string>[2]> where,
33
+ * [1] cookie key
34
+ * [2] {
35
+ * value: cookie value,
36
+ * setCookie: setCookie used in browser response
37
+ * }
38
+ */
39
+ export declare const getUpdatedCookie: (ctx: ContextForCookies) => string | null;
40
+ export declare const getWithCookie: (ctx: ContextForCookies) => <T extends Record<string, any>>(headers: T) => T | (T & {
41
+ cookie: string;
42
+ });
43
+ /**
44
+ * This function updates the cookie value based on its key
45
+ *
46
+ * const existingCookies = 'key=value1; key2=; key3=value3';
47
+ * const storageCookieKey = 'key2';
48
+ * const storageCookieValue = 'value2'
49
+ *
50
+ * updatesCookieValueByKey(existingCookies, storageCookieKey, storageCookieValue) returns 'key=value1; key2=value2; key3=value3';
51
+ */
52
+ export declare const updatesCookieValueByKey: (existingCookies: string, storageCookieKey: string, storageCookieValue: string) => string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/api",
3
- "version": "1.12.50",
3
+ "version": "1.12.52",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -53,5 +53,5 @@
53
53
  "@envelop/core": "^1 || ^2",
54
54
  "graphql": "^15.6.0"
55
55
  },
56
- "gitHead": "6a034d875598025c79f2c6ff6b7801bf0c955aef"
56
+ "gitHead": "f3dba5bfc0d3516fec52145565dae375263d511e"
57
57
  }
@@ -19,6 +19,7 @@ import type { SalesChannel } from './types/SalesChannel'
19
19
  import { MasterDataResponse } from './types/Newsletter'
20
20
  import type { Address, AddressInput } from './types/Address'
21
21
  import { ShippingDataBody } from './types/ShippingData'
22
+ import { getStoreCookie, getWithCookie } from '../../utils/cookies'
22
23
 
23
24
  type ValueOf<T> = T extends Record<string, infer K> ? K : never
24
25
 
@@ -29,11 +30,56 @@ const BASE_INIT = {
29
30
  },
30
31
  }
31
32
 
33
+ // Accounts already warned that they rely on public `NL` fields. The commerce
34
+ // client is created per request, so the warning is kept once per account
35
+ // instead of per instance, to avoid flooding the logs. The account comes from
36
+ // the store's API options, not from the request, so the set holds at most one
37
+ // entry per account the process is configured for (usually one). It's still
38
+ // capped, so it can't grow without bound if that ever changes.
39
+ const MAX_WARNED_NEWSLETTER_ACCOUNTS = 100
40
+ const accountsWarnedAboutAnonymousNewsletter = new Set<string>()
41
+
42
+ /** Test-only: forget which accounts were warned about the anonymous fallback. */
43
+ export const resetAnonymousNewsletterWarnings = () =>
44
+ accountsWarnedAboutAnonymousNewsletter.clear()
45
+
46
+ const getHeader = (headers: Context['headers'] | undefined, name: string) => {
47
+ const value: string | string[] | undefined = (headers as any)?.[name]
48
+
49
+ return Array.isArray(value) ? value[0] : value
50
+ }
51
+
52
+ /**
53
+ * Host the Checkout API uses as the domain of the cookies it sets. Checkout
54
+ * only honors it for hosts registered in the account, so a forged value is
55
+ * ignored there.
56
+ */
57
+ const getForwardedHost = (headers: Context['headers'] | undefined) => {
58
+ // Proxies may append values: "client, proxy". Only the first one is the store host.
59
+ const host =
60
+ getHeader(headers, 'x-forwarded-host')?.split(',')[0].trim() ||
61
+ getHeader(headers, 'host') ||
62
+ ''
63
+
64
+ return host.trim().replace(/^www\./, '')
65
+ }
66
+
32
67
  export const VtexCommerce = (
33
68
  { account, environment }: Options,
34
69
  ctx: Context
35
70
  ) => {
36
71
  const base = `https://${account}.${environment}.com.br`
72
+ const storeCookies = getStoreCookie(ctx)
73
+ const withCookie = getWithCookie(ctx)
74
+ const forwardedHost = getForwardedHost(ctx.headers)
75
+
76
+ // Checkout calls forward the shopper cookies and keep the cookies Checkout
77
+ // sets (e.g. CheckoutOrderFormOwnership) so they can reach the browser.
78
+ const checkoutHeaders = () =>
79
+ withCookie({
80
+ 'content-type': 'application/json',
81
+ 'X-FORWARDED-HOST': forwardedHost,
82
+ })
37
83
 
38
84
  return {
39
85
  catalog: {
@@ -107,8 +153,10 @@ export const VtexCommerce = (
107
153
  `${base}/api/checkout/pub/orderForm/${id}/attachments/shippingData`,
108
154
  {
109
155
  ...BASE_INIT,
156
+ headers: checkoutHeaders(),
110
157
  body: JSON.stringify(body),
111
- }
158
+ },
159
+ { storeCookies }
112
160
  )
113
161
  },
114
162
  orderForm: ({
@@ -128,7 +176,11 @@ export const VtexCommerce = (
128
176
 
129
177
  return fetchAPI(
130
178
  `${base}/api/checkout/pub/orderForm/${id}?${params.toString()}`,
131
- BASE_INIT
179
+ {
180
+ ...BASE_INIT,
181
+ headers: checkoutHeaders(),
182
+ },
183
+ { storeCookies }
132
184
  )
133
185
  },
134
186
  updateOrderFormItems: ({
@@ -153,12 +205,14 @@ export const VtexCommerce = (
153
205
  `${base}/api/checkout/pub/orderForm/${id}/items?${params}`,
154
206
  {
155
207
  ...BASE_INIT,
208
+ headers: checkoutHeaders(),
156
209
  body: JSON.stringify({
157
210
  orderItems,
158
211
  noSplitItem: !shouldSplitItem,
159
212
  }),
160
213
  method: 'PATCH',
161
- }
214
+ },
215
+ { storeCookies }
162
216
  )
163
217
  },
164
218
  setCustomData: ({
@@ -176,9 +230,11 @@ export const VtexCommerce = (
176
230
  `${base}/api/checkout/pub/orderForm/${id}/customData/${appId}/${key}`,
177
231
  {
178
232
  ...BASE_INIT,
233
+ headers: checkoutHeaders(),
179
234
  body: JSON.stringify({ value }),
180
235
  method: 'PUT',
181
- }
236
+ },
237
+ { storeCookies }
182
238
  )
183
239
  },
184
240
  region: async ({
@@ -217,13 +273,71 @@ export const VtexCommerce = (
217
273
  body: '{}',
218
274
  })
219
275
  },
220
- subscribeToNewsletter: (data: {
221
- name: string
276
+ /**
277
+ * Creates a newsletter subscription in the `NL` Master Data entity.
278
+ *
279
+ * With `FS_NEWSLETTER_APP_KEY`/`FS_NEWSLETTER_APP_TOKEN` set, the document is
280
+ * created with a `POST` authenticated by that dedicated key, so `NL` fields
281
+ * can stay private. The request is create-only: the body is built here from
282
+ * the validated fields and never carries a document id, so the caller can't
283
+ * reach an existing record.
284
+ *
285
+ * Without the credentials, it falls back to the legacy anonymous `PATCH`,
286
+ * which only works while `NL` fields are public.
287
+ */
288
+ subscribeToNewsletter: ({
289
+ name,
290
+ email,
291
+ }: {
292
+ name?: string
222
293
  email: string
223
294
  }): Promise<MasterDataResponse> => {
295
+ const body = JSON.stringify({
296
+ ...(name ? { name } : {}),
297
+ email,
298
+ isNewsletterOptIn: true,
299
+ })
300
+ const appKey = process.env.FS_NEWSLETTER_APP_KEY
301
+ const appToken = process.env.FS_NEWSLETTER_APP_TOKEN
302
+
303
+ if (appKey && appToken) {
304
+ return fetchAPI(`${base}/api/dataentities/NL/documents`, {
305
+ method: 'POST',
306
+ headers: {
307
+ 'content-type': 'application/json',
308
+ accept: 'application/json',
309
+ 'X-VTEX-API-AppKey': appKey,
310
+ 'X-VTEX-API-AppToken': appToken,
311
+ },
312
+ body,
313
+ })
314
+ }
315
+
316
+ if (!accountsWarnedAboutAnonymousNewsletter.has(account)) {
317
+ if (
318
+ accountsWarnedAboutAnonymousNewsletter.size >=
319
+ MAX_WARNED_NEWSLETTER_ACCOUNTS
320
+ ) {
321
+ accountsWarnedAboutAnonymousNewsletter.clear()
322
+ }
323
+ accountsWarnedAboutAnonymousNewsletter.add(account)
324
+
325
+ // Name only the missing variables, so a partial setup is easy to spot.
326
+ const missing = [
327
+ !appKey && 'FS_NEWSLETTER_APP_KEY',
328
+ !appToken && 'FS_NEWSLETTER_APP_TOKEN',
329
+ ].filter(Boolean)
330
+
331
+ console.warn(
332
+ `[subscribeToNewsletter] ${missing.join(' and ')} ${
333
+ missing.length > 1 ? 'are' : 'is'
334
+ } not set. Falling back to an anonymous Master Data request, which requires public fields in the NL entity. Configure both credentials and make the NL fields private.`
335
+ )
336
+ }
337
+
224
338
  return fetchAPI(`${base}/api/dataentities/NL/documents/`, {
225
339
  ...BASE_INIT,
226
- body: JSON.stringify({ ...data, isNewsletterOptIn: true }),
340
+ body,
227
341
  method: 'PATCH',
228
342
  })
229
343
  },
@@ -3,7 +3,50 @@ import packageJson from '../../../../package.json'
3
3
 
4
4
  const USER_AGENT = `${packageJson.name}@${packageJson.version}`
5
5
 
6
- export const fetchAPI = async (info: RequestInfo, init?: RequestInit) => {
6
+ // Headers that may carry credentials: store API keys (`X-VTEX-API-AppKey`,
7
+ // `X-VTEX-API-AppToken`), shopper cookies (`Cookie`, `Set-Cookie`,
8
+ // `VtexIdclientAutCookie_*`), `Authorization`, and generic token, secret or
9
+ // API key headers. They are replaced before a failed request and its response
10
+ // are logged. Matched by substring so new credential headers are covered too.
11
+ const SENSITIVE_HEADER = /authorization|cookie|token|secret|api-?key|appkey/i
12
+
13
+ const REDACTED = '[redacted]'
14
+
15
+ export const redactHeaders = (
16
+ headers?: HeadersInit
17
+ ): Record<string, string> | undefined => {
18
+ if (!headers) {
19
+ return undefined
20
+ }
21
+
22
+ const redacted: Record<string, string> = {}
23
+ const add = (name: string, value: string) => {
24
+ redacted[name] = SENSITIVE_HEADER.test(name) ? REDACTED : value
25
+ }
26
+
27
+ if (Array.isArray(headers)) {
28
+ headers.forEach(([name, value]) => add(name, value))
29
+ } else if (typeof (headers as Headers).forEach === 'function') {
30
+ // Matches both the global `Headers` and the polyfill returned by
31
+ // `isomorphic-unfetch` on Node, which isn't an instance of the global one.
32
+ const headerList = headers as Headers
33
+ headerList.forEach((value, name) => add(name, value))
34
+ } else {
35
+ Object.entries(headers).forEach(([name, value]) => add(name, value))
36
+ }
37
+
38
+ return redacted
39
+ }
40
+
41
+ interface FetchAPIOptions {
42
+ storeCookies?: (headers: Headers) => void
43
+ }
44
+
45
+ export const fetchAPI = async (
46
+ info: RequestInfo,
47
+ init?: RequestInit,
48
+ options?: FetchAPIOptions
49
+ ) => {
7
50
  const response = await fetch(info, {
8
51
  ...init,
9
52
  headers: {
@@ -13,10 +56,30 @@ export const fetchAPI = async (info: RequestInfo, init?: RequestInit) => {
13
56
  })
14
57
 
15
58
  if (response.ok) {
59
+ if (options?.storeCookies) {
60
+ options.storeCookies(response.headers)
61
+ }
62
+
16
63
  return response.status !== 204 ? response.json() : undefined
17
64
  }
18
65
 
19
- console.error(info, init, response)
66
+ // Log a summary instead of the raw Response, whose headers may include
67
+ // `Set-Cookie` with shopper credentials. The request body is omitted because
68
+ // it can carry personal data (e.g. the newsletter email and name).
69
+ console.error(
70
+ // Only the URL: a `Request` instance could carry unredacted headers.
71
+ typeof info === 'string' ? info : info.url,
72
+ init && {
73
+ ...init,
74
+ headers: redactHeaders(init.headers),
75
+ body: init.body ? REDACTED : undefined,
76
+ },
77
+ {
78
+ status: response.status,
79
+ statusText: response.statusText,
80
+ headers: redactHeaders(response.headers),
81
+ }
82
+ )
20
83
  const text = await response.text()
21
84
 
22
85
  throw new Error(text)
@@ -56,6 +56,11 @@ export interface Context {
56
56
  locale: string
57
57
  flags: FeatureFlags
58
58
  searchArgs?: Omit<SearchArgs, 'type'>
59
+ /**
60
+ * Checkout cookies received while resolving this request, keyed by cookie
61
+ * name. The GraphQL handler forwards `setCookie` values to the browser.
62
+ */
63
+ cookies: Map<string, Record<string, string>>
59
64
  }
60
65
  headers: Record<string, string>
61
66
  }
@@ -96,6 +101,7 @@ export const getContextFactory =
96
101
  channel: ChannelMarshal.parse(options.channel),
97
102
  flags: options.flags ?? {},
98
103
  locale: options.locale,
104
+ cookies: new Map<string, Record<string, string>>(),
99
105
  }
100
106
  ctx.clients = getClients(options, ctx)
101
107
  ctx.loaders = getLoaders(options, ctx)
@@ -3,12 +3,38 @@ import type {
3
3
  MutationSubscribeToNewsletterArgs,
4
4
  PersonNewsletter,
5
5
  } from '../../../__generated__/schema'
6
+ import { BadRequestError } from '../../errors'
7
+
8
+ const EMAIL_MAX_LENGTH = 254
9
+ const NAME_MAX_LENGTH = 100
10
+ // Character classes exclude `.`, so each domain label is matched once and the
11
+ // pattern stays linear on long inputs.
12
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
6
13
 
7
14
  export const subscribeToNewsletter = async (
8
15
  _: any,
9
16
  { data }: MutationSubscribeToNewsletterArgs,
10
17
  { clients: { commerce } }: Context
11
18
  ): Promise<PersonNewsletter | null> => {
12
- const response = await commerce.subscribeToNewsletter(data)
19
+ // The subscription is written with a store credential, so only validated
20
+ // values reach Master Data.
21
+ const email = data.email?.trim().toLowerCase() ?? ''
22
+ const name = data.name?.trim() ?? ''
23
+
24
+ if (email.length > EMAIL_MAX_LENGTH || !EMAIL_PATTERN.test(email)) {
25
+ throw new BadRequestError('Invalid email')
26
+ }
27
+
28
+ if (name.length > NAME_MAX_LENGTH) {
29
+ throw new BadRequestError(
30
+ `Invalid name: must have at most ${NAME_MAX_LENGTH} characters`
31
+ )
32
+ }
33
+
34
+ const response = await commerce.subscribeToNewsletter({
35
+ email,
36
+ name: name || undefined,
37
+ })
38
+
13
39
  return { id: response?.Id }
14
40
  }
@@ -0,0 +1,157 @@
1
+ import type { Context } from '../index'
2
+
3
+ export interface ContextForCookies {
4
+ headers: Context['headers']
5
+ storage: Pick<Context['storage'], 'cookies'>
6
+ }
7
+
8
+ const MATCH_FIRST_SET_COOKIE_KEY_VALUE = /^([^=]+)=([^;]*)/
9
+
10
+ /**
11
+ * Splits a combined `set-cookie` header value (cookies joined by `, `) into
12
+ * individual cookies. Commas inside `expires=...` dates are kept.
13
+ */
14
+ const splitSetCookieHeader = (value: string) =>
15
+ value.split(/,(?=\s*[^;,=\s]+=)/).map((setCookie) => setCookie.trim())
16
+
17
+ /**
18
+ * Returns every `set-cookie` value of a fetch response.
19
+ *
20
+ * `Headers.getSetCookie()` only exists on Node >= 18.14 (undici). FastStore v1
21
+ * stores may still run on Node 16, where isomorphic-unfetch falls back to
22
+ * node-fetch v2, which exposes repeated headers through `raw()` instead.
23
+ */
24
+ export const getSetCookies = (headers: Headers): string[] => {
25
+ const maybeHeaders = headers as Headers & {
26
+ getSetCookie?: () => string[]
27
+ raw?: () => Record<string, string[]>
28
+ }
29
+
30
+ if (typeof maybeHeaders.getSetCookie === 'function') {
31
+ return maybeHeaders.getSetCookie()
32
+ }
33
+
34
+ if (typeof maybeHeaders.raw === 'function') {
35
+ return maybeHeaders.raw()['set-cookie'] ?? []
36
+ }
37
+
38
+ const setCookie = headers.get('set-cookie')
39
+
40
+ return setCookie ? splitSetCookieHeader(setCookie) : []
41
+ }
42
+
43
+ /**
44
+ * This function updates the ctx.storage.cookies, that is used in each request.
45
+ *
46
+ * ctx.storage.cookies is a Map<string[1], Record<string, string>[2]> where,
47
+ * [1] cookie key
48
+ * [2] {
49
+ * value: cookie value,
50
+ * setCookie: setCookie used in browser response
51
+ * }
52
+ */
53
+ export const updatesContextStorageCookies = (
54
+ ctx: Pick<Context, 'storage'>,
55
+ setCookieValue: string
56
+ ) => {
57
+ const matchCookie = setCookieValue.match(MATCH_FIRST_SET_COOKIE_KEY_VALUE)
58
+
59
+ if (matchCookie) {
60
+ const cookieKey = matchCookie[1]
61
+ const cookieValue = matchCookie[2]
62
+
63
+ ctx.storage.cookies.set(cookieKey, {
64
+ value: cookieValue,
65
+ setCookie: setCookieValue,
66
+ })
67
+ }
68
+ }
69
+
70
+ export const setCookie = (ctx: Pick<Context, 'storage'>, headers: Headers) => {
71
+ getSetCookies(headers).forEach((setCookieValue) =>
72
+ updatesContextStorageCookies(ctx, setCookieValue)
73
+ )
74
+ }
75
+
76
+ export const getStoreCookie =
77
+ (ctx: Pick<Context, 'storage'>) => (headers: Headers) =>
78
+ setCookie(ctx, headers)
79
+
80
+ /**
81
+ * This function returns a modified copy of the original cookie header (ctx.headers.cookie from the first request)
82
+ * with the cookie values that comes in each request (ctx.storage.cookies).
83
+ * If there is no cookies in storage, the ctx.headers?.cookie is used
84
+ *
85
+ * ctx.storage.cookies is a Map<string[1], Record<string, string>[2]> where,
86
+ * [1] cookie key
87
+ * [2] {
88
+ * value: cookie value,
89
+ * setCookie: setCookie used in browser response
90
+ * }
91
+ */
92
+ export const getUpdatedCookie = (ctx: ContextForCookies) => {
93
+ if (!ctx.headers?.cookie) {
94
+ return null
95
+ }
96
+
97
+ const contextStorageCookies = Array.from(ctx.storage.cookies.entries())
98
+
99
+ if (contextStorageCookies.length === 0) {
100
+ return ctx.headers.cookie
101
+ }
102
+
103
+ return contextStorageCookies.reduce(
104
+ (existingCookies, [storageCookieKey, { value: storageCookieValue }]) =>
105
+ updatesCookieValueByKey(
106
+ existingCookies,
107
+ storageCookieKey,
108
+ storageCookieValue
109
+ ),
110
+ ctx.headers.cookie
111
+ )
112
+ }
113
+
114
+ export const getWithCookie = (ctx: ContextForCookies) =>
115
+ function withCookie<T extends Record<string, any>>(
116
+ headers: T
117
+ ): T | (T & { cookie: string }) {
118
+ const updatedCookie = getUpdatedCookie(ctx)
119
+
120
+ if (!updatedCookie) {
121
+ return headers
122
+ }
123
+
124
+ return {
125
+ ...headers,
126
+ cookie: updatedCookie,
127
+ }
128
+ }
129
+
130
+ /**
131
+ * This function updates the cookie value based on its key
132
+ *
133
+ * const existingCookies = 'key=value1; key2=; key3=value3';
134
+ * const storageCookieKey = 'key2';
135
+ * const storageCookieValue = 'value2'
136
+ *
137
+ * updatesCookieValueByKey(existingCookies, storageCookieKey, storageCookieValue) returns 'key=value1; key2=value2; key3=value3';
138
+ */
139
+ export const updatesCookieValueByKey = (
140
+ existingCookies: string,
141
+ storageCookieKey: string,
142
+ storageCookieValue: string
143
+ ) => {
144
+ const MATCH_COOKIE_KEY_VALUE = new RegExp(`(${storageCookieKey})=([^;]*)`)
145
+ const cookieParts = existingCookies.match(MATCH_COOKIE_KEY_VALUE)
146
+
147
+ // replaces original cookie with the one coming from storage
148
+ if (cookieParts) {
149
+ return existingCookies.replace(
150
+ cookieParts[0],
151
+ `${cookieParts[1]}=${storageCookieValue}`
152
+ )
153
+ }
154
+
155
+ // add new storage cookie to the original list of cookies
156
+ return `${existingCookies};${storageCookieKey}=${storageCookieValue}`
157
+ }