@faststore/core 4.3.0-dev.0 → 4.3.0-dev.1

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.
@@ -1,9 +1,9 @@
1
1
 
2
- > @faststore/core@4.2.0 generate /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@4.3.0-dev.0 generate /home/runner/work/faststore/faststore/packages/core
3
3
  > pnpm run gen-types && pnpm run cache-graphql
4
4
 
5
5
 
6
- > @faststore/core@4.2.0 gen-types /home/runner/work/faststore/faststore/packages/core
6
+ > @faststore/core@4.3.0-dev.0 gen-types /home/runner/work/faststore/faststore/packages/core
7
7
  > node ../cli/bin/run generate-types .
8
8
 
9
9
  [STARTED] Parse Configuration
@@ -19,7 +19,7 @@
19
19
  [COMPLETED] Generate to /home/runner/work/faststore/faststore/packages/core/@generated/
20
20
  [COMPLETED] Generate outputs
21
21
 
22
- > @faststore/core@4.2.0 cache-graphql /home/runner/work/faststore/faststore/packages/core
22
+ > @faststore/core@4.3.0-dev.0 cache-graphql /home/runner/work/faststore/faststore/packages/core
23
23
  > node ../cli/bin/run cache-graphql --config=./discovery.config.default.js --queries=./@generated/persisted-documents.json
24
24
 
25
25
  [Info] - Config file location: /home/runner/work/faststore/faststore/packages/core/discovery.config.default.js
@@ -1,12 +1,10 @@
1
1
 
2
- > @faststore/core@4.2.0 test /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@4.3.0-dev.0 test /home/runner/work/faststore/faststore/packages/core
3
3
  > vitest run
4
4
 
5
5
 
6
6
   RUN  v4.0.7 /home/runner/work/faststore/faststore/packages/core
7
7
 
8
- ✓  browser  test/sdk/session/getInitialSession.browser.test.ts (6 tests) 10ms
9
- ✓  browser  test/sdk/session/installLocaleCorrector.browser.test.ts (7 tests) 43ms
10
8
  stderr | test/sdk/localization/store-url.browser.test.ts
11
9
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
12
10
  at validateCart (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/index.ts:119:27)
@@ -14,31 +12,79 @@
14
12
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:353:23)
15
13
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
16
14
 
17
- ✓  node  test/utils/localization/bindingPaths.test.ts (71 tests) 76ms
18
- ✓  browser  test/utils/isLocalHost.browser.test.ts (3 tests) 7ms
19
- ✓  node  test/utils/match-url.test.ts (10 tests) 37ms
20
- ✓  browser  test/sdk/localization/store-url.browser.test.ts (3 tests) 12ms
21
- ✓  node  test/sdk/localization/bindingSelector.test.ts (18 tests) 28ms
22
- ✓  node  test/sdk/localization/useBindingSelector.test.tsx (12 tests) 23ms
23
- ✓  node  test/utils/cookieCacheBusting.test.ts (10 tests) 33ms
24
- ✓  node  test/utils/multipleTemplates.test.ts (8 tests) 22ms
25
- ✓  node  test/utils/clearCookies.test.ts (20 tests) 105ms
26
- ✓  node  test/sdk/search/useSearchHistory.test.ts (13 tests) 203ms
27
- ✓  node  test/server/cms/global.test.ts (3 tests) 29ms
28
- ✓  node  test/server/content/service.test.ts (5 tests) 15ms
15
+ ✓  browser  test/sdk/session/getInitialSession.browser.test.ts (6 tests) 42ms
16
+ ✓  browser  test/sdk/session/installLocaleCorrector.browser.test.ts (7 tests) 62ms
17
+ ✓  node  test/utils/localization/bindingPaths.test.ts (71 tests) 74ms
18
+ ✓  browser  test/sdk/localization/store-url.browser.test.ts (3 tests) 29ms
19
+ ✓  node  test/utils/match-url.test.ts (10 tests) 38ms
20
+ ✓  node  test/sdk/localization/bindingSelector.test.ts (18 tests) 23ms
21
+ ✓  browser  test/utils/isLocalHost.browser.test.ts (3 tests) 15ms
22
+ ✓  node  test/sdk/localization/useBindingSelector.test.tsx (12 tests) 20ms
23
+ ✓  node  test/utils/cookieCacheBusting.test.ts (10 tests) 44ms
24
+ ✓  node  test/sdk/search/useSearchHistory.test.ts (13 tests) 224ms
25
+ ✓  node  test/utils/multipleTemplates.test.ts (8 tests) 15ms
26
+ ✓  node  test/utils/clearCookies.test.ts (20 tests) 147ms
27
+ ✓  node  test/server/content/service.test.ts (5 tests) 12ms
28
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates the upstream 400 from a wrapped BadRequestError instead of 500
29
+ Graphql execution returned with error: [
30
+ {
31
+ message: 'O campo CEP (88) é inválido',
32
+ status: 400,
33
+ type: 'BadRequestError'
34
+ }
35
+ ]
36
+
37
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates UnauthorizedError status
38
+ Graphql execution returned with error: [ { message: 'nope', status: 401, type: 'UnauthorizedError' } ]
39
+
40
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates ForbiddenError status
41
+ Graphql execution returned with error: [ { message: 'nope', status: 403, type: 'ForbiddenError' } ]
42
+
43
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates NotFoundError status
44
+ Graphql execution returned with error: [ { message: 'nope', status: 404, type: 'NotFoundError' } ]
45
+
46
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates an unmapped upstream status (UnknownError 429)
47
+ Graphql execution returned with error: [ { message: 'slow down', status: 429, type: 'UnknownError' } ]
48
+
49
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > recovers status when the error itself is a FastStoreError (not wrapped)
50
+ Graphql execution returned with error: [ { message: 'bad input', status: 400, type: 'BadRequestError' } ]
51
+
52
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > falls back to a body-less 500 for non-FastStore errors
53
+ Graphql execution returned with error: [
54
+ {
55
+ message: 'Sorry, something went wrong.',
56
+ status: undefined,
57
+ type: undefined
58
+ }
59
+ ]
60
+
61
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > exposes type, status and message in the body outside production
62
+ Graphql execution returned with error: [ { message: 'invalid CEP', status: 400, type: 'BadRequestError' } ]
63
+
64
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > omits the free-text message from the body in production
65
+ Graphql execution returned with error: [ { message: 'invalid CEP', status: 400, type: 'BadRequestError' } ]
66
+
67
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > uses the first recoverable FastStoreError when several errors exist
68
+ Graphql execution returned with error: [
69
+ { message: 'plain error', status: undefined, type: undefined },
70
+ { message: 'missing', status: 404, type: 'NotFoundError' },
71
+ { message: 'bad', status: 400, type: 'BadRequestError' }
72
+ ]
73
+
74
+ ✓  node  test/pages/api/graphql.test.ts (10 tests) 72ms
75
+ ✓  node  test/server/cms/global.test.ts (3 tests) 19ms
76
+ ✓  node  test/utils/getRequestHostname.test.ts (12 tests) 23ms
29
77
  ✓  node  test/sdk/localization/store-url.test.ts (1 test) 12ms
30
- ✓  node  test/utils/getRequestHostname.test.ts (12 tests) 14ms
31
78
  ✓  node  test/utils/validateSessionRefreshToken.test.ts (6 tests) 14ms
32
- ✓  node  test/pages/api/preview.test.ts (2 tests) 25ms
33
- ✓  node  test/utils/isLocalHost.test.ts (7 tests) 18ms
34
- ✓  node  test/server/cms/index.test.ts (2 tests) 18ms
35
- ✓  node  test/server/index.test.ts (7 tests) 1197ms
36
- ✓ should return a valid merged GraphQL schema  392ms
37
- ✓ should exist with its plugins  309ms
38
- ✓ should handle options and execute  482ms
39
-
40
-  Test Files  21 passed (21)
41
-  Tests  226 passed (226)
42
-  Start at  22:01:31
43
-  Duration  17.15s (transform 6.17s, setup 0ms, collect 17.85s, tests 1.94s, environment 15.50s, prepare 929ms)
79
+ ✓  node  test/pages/api/preview.test.ts (2 tests) 18ms
80
+ ✓  node  test/utils/isLocalHost.test.ts (7 tests) 8ms
81
+ ✓  node  test/server/cms/index.test.ts (2 tests) 4ms
82
+ ✓  node  test/server/index.test.ts (7 tests) 1333ms
83
+ ✓ should return a valid merged GraphQL schema  658ms
84
+ ✓ should exist with its plugins  390ms
85
+
86
+  Test Files  22 passed (22)
87
+  Tests  236 passed (236)
88
+  Start at  14:05:33
89
+  Duration  15.91s (transform 5.65s, setup 0ms, collect 17.89s, tests 2.25s, environment 14.22s, prepare 794ms)
44
90
 
package/CHANGELOG.md CHANGED
@@ -3,6 +3,12 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [4.3.0-dev.1](https://github.com/vtex/faststore/compare/v4.3.0-dev.0...v4.3.0-dev.1) (2026-06-08)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **core:** propagate upstream error status instead of always 500 ([#3379](https://github.com/vtex/faststore/issues/3379)) ([c03d3df](https://github.com/vtex/faststore/commit/c03d3dff7f5920e834a318417a4a02cc653453cf))
11
+
6
12
  # [4.3.0-dev.0](https://github.com/vtex/faststore/compare/v4.2.0...v4.3.0-dev.0) (2026-06-01)
7
13
 
8
14
  **Note:** Version bump only for package @faststore/core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "4.3.0-dev.0",
3
+ "version": "4.3.0-dev.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -70,11 +70,11 @@
70
70
  "style-loader": "^3.3.1",
71
71
  "swr": "^2.2.5",
72
72
  "use-sync-external-store": "^1.6.0",
73
- "@faststore/lighthouse": "4.3.0-dev.0",
74
- "@faststore/diagnostics": "4.3.0-dev.0",
75
- "@faststore/sdk": "4.3.0-dev.0",
76
- "@faststore/ui": "4.3.0-dev.0",
77
- "@faststore/api": "4.3.0-dev.0"
73
+ "@faststore/diagnostics": "4.3.0-dev.1",
74
+ "@faststore/api": "4.3.0-dev.1",
75
+ "@faststore/sdk": "4.3.0-dev.1",
76
+ "@faststore/lighthouse": "4.3.0-dev.1",
77
+ "@faststore/ui": "4.3.0-dev.1"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@cypress/code-coverage": "^3.12.1",
@@ -18,6 +18,21 @@ const DEFAULT_MAX_AGE = 5 * 60 // 5 minutes
18
18
  const DEFAULT_STALE_WHILE_REVALIDATE = 60 * 60 // 1 hour
19
19
  const ALLOWED_HOST_SUFFIXES = ['localhost', '.vtex.app', '.localhost']
20
20
 
21
+ /**
22
+ * Recovers a FastStoreError from an execution error, whether it is the error
23
+ * itself or wrapped as the `originalError` of a masked GraphQLError.
24
+ */
25
+ const recoverFastStoreError = (graphqlError: unknown) => {
26
+ if (isFastStoreError(graphqlError)) {
27
+ return graphqlError
28
+ }
29
+
30
+ const originalError = (graphqlError as { originalError?: unknown })
31
+ ?.originalError
32
+
33
+ return isFastStoreError(originalError) ? originalError : undefined
34
+ }
35
+
21
36
  // Example: "Set-Cookie: key=value; Domain=example.com; Path=/"
22
37
  const MATCH_DOMAIN_REGEXP = /(?:^|;\s*)(?:domain=)([^;]+)/i
23
38
 
@@ -186,13 +201,53 @@ const handler: NextApiHandler = async (request, response) => {
186
201
  { headers: request.headers }
187
202
  )
188
203
 
189
- const hasErrors = Array.isArray(errors)
204
+ const hasErrors = Array.isArray(errors) && errors.length > 0
190
205
 
191
206
  if (hasErrors) {
192
- const error = errors.find(isFastStoreError)
193
- console.error('Graphql execution returned with error: ', error)
207
+ // After error masking, entries are GraphQLError instances whose
208
+ // `name` is "GraphQLError" the original FastStoreError (carrying the
209
+ // upstream status) is nested in `originalError`. Recover it so the BFF
210
+ // propagates the real status instead of collapsing everything to 500.
211
+ const fastStoreError = errors.map(recoverFastStoreError).find(Boolean)
212
+
213
+ console.error(
214
+ 'Graphql execution returned with error: ',
215
+ errors.map((graphqlError) => {
216
+ const fsError = recoverFastStoreError(graphqlError)
217
+
218
+ return {
219
+ message: (graphqlError as { message?: string })?.message,
220
+ status: fsError?.extensions.status,
221
+ type: fsError?.extensions.type,
222
+ }
223
+ })
224
+ )
225
+
226
+ const status = fastStoreError?.extensions.status ?? 500
227
+
228
+ // No recoverable FastStoreError: keep the masked, body-less 500.
229
+ if (!fastStoreError) {
230
+ response.status(status).end()
231
+ return
232
+ }
233
+
234
+ // Only FastStoreError-derived details are exposed. `type`/`status` are a
235
+ // closed enum (safe everywhere); the free-text `message` may echo raw
236
+ // upstream text, so it is restricted to non-production responses. The
237
+ // full message is still available server-side via the log above.
238
+ const responseError = {
239
+ extensions: {
240
+ type: fastStoreError.extensions.type,
241
+ status: fastStoreError.extensions.status,
242
+ },
243
+ ...(process.env.NODE_ENV !== 'production'
244
+ ? { message: fastStoreError.message }
245
+ : {}),
246
+ }
194
247
 
195
- response.status(error?.extensions.status ?? 500).end()
248
+ response.status(status)
249
+ response.setHeader('content-type', 'application/json')
250
+ response.send(JSON.stringify({ errors: [responseError] }))
196
251
  return
197
252
  }
198
253
 
@@ -0,0 +1,167 @@
1
+ import {
2
+ BadRequestError,
3
+ FastStoreError,
4
+ ForbiddenError,
5
+ NotFoundError,
6
+ UnauthorizedError,
7
+ } from '@faststore/api'
8
+ import { GraphQLError } from 'graphql'
9
+ import type { NextApiRequest, NextApiResponse } from 'next'
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
+
12
+ import handler from '../../../src/pages/api/graphql'
13
+ import { execute } from '../../../src/server'
14
+
15
+ vi.mock('../../../src/server', () => ({
16
+ execute: vi.fn(),
17
+ }))
18
+
19
+ const mockedExecute = vi.mocked(execute)
20
+
21
+ const createResponse = () => {
22
+ const res = {
23
+ status: vi.fn().mockReturnThis(),
24
+ setHeader: vi.fn().mockReturnThis(),
25
+ send: vi.fn().mockReturnThis(),
26
+ end: vi.fn().mockReturnThis(),
27
+ }
28
+
29
+ return res as unknown as NextApiResponse & typeof res
30
+ }
31
+
32
+ const createRequest = (operationName = 'ClientShippingSimulationQuery') =>
33
+ ({
34
+ method: 'GET',
35
+ headers: { host: 'localhost:3000' },
36
+ query: {
37
+ operationName,
38
+ operationHash: 'hash',
39
+ variables: '{}',
40
+ },
41
+ }) as unknown as NextApiRequest
42
+
43
+ const mockExecuteWithErrors = (errors: unknown[]) => {
44
+ mockedExecute.mockResolvedValue({
45
+ data: null,
46
+ errors,
47
+ extensions: { cookies: new Map(), cacheControl: undefined },
48
+ } as never)
49
+ }
50
+
51
+ // Reproduces what `useMaskedErrors` produces: a GraphQLError whose
52
+ // `originalError` is the FastStoreError thrown by a resolver/`fetchAPI`.
53
+ const maskedError = (original: FastStoreError) =>
54
+ new GraphQLError(original.message, { originalError: original })
55
+
56
+ describe('/api/graphql error status propagation', () => {
57
+ beforeEach(() => {
58
+ vi.clearAllMocks()
59
+ })
60
+
61
+ afterEach(() => {
62
+ vi.unstubAllEnvs()
63
+ })
64
+
65
+ it('propagates the upstream 400 from a wrapped BadRequestError instead of 500', async () => {
66
+ mockExecuteWithErrors([
67
+ maskedError(new BadRequestError('O campo CEP (88) é inválido')),
68
+ ])
69
+
70
+ const res = createResponse()
71
+ await handler(createRequest(), res)
72
+
73
+ expect(res.status).toHaveBeenCalledWith(400)
74
+ expect(res.end).not.toHaveBeenCalled()
75
+ })
76
+
77
+ it.each([
78
+ ['UnauthorizedError', () => new UnauthorizedError('nope'), 401],
79
+ ['ForbiddenError', () => new ForbiddenError('nope'), 403],
80
+ ['NotFoundError', () => new NotFoundError('nope'), 404],
81
+ ])('propagates %s status', async (_name, makeError, expectedStatus) => {
82
+ mockExecuteWithErrors([maskedError(makeError())])
83
+
84
+ const res = createResponse()
85
+ await handler(createRequest(), res)
86
+
87
+ expect(res.status).toHaveBeenCalledWith(expectedStatus)
88
+ })
89
+
90
+ it('propagates an unmapped upstream status (UnknownError 429)', async () => {
91
+ mockExecuteWithErrors([
92
+ maskedError(
93
+ new FastStoreError({ status: 429, type: 'UnknownError' }, 'slow down')
94
+ ),
95
+ ])
96
+
97
+ const res = createResponse()
98
+ await handler(createRequest(), res)
99
+
100
+ expect(res.status).toHaveBeenCalledWith(429)
101
+ })
102
+
103
+ it('recovers status when the error itself is a FastStoreError (not wrapped)', async () => {
104
+ mockExecuteWithErrors([new BadRequestError('bad input')])
105
+
106
+ const res = createResponse()
107
+ await handler(createRequest(), res)
108
+
109
+ expect(res.status).toHaveBeenCalledWith(400)
110
+ })
111
+
112
+ it('falls back to a body-less 500 for non-FastStore errors', async () => {
113
+ mockExecuteWithErrors([new GraphQLError('Sorry, something went wrong.')])
114
+
115
+ const res = createResponse()
116
+ await handler(createRequest(), res)
117
+
118
+ expect(res.status).toHaveBeenCalledWith(500)
119
+ expect(res.end).toHaveBeenCalled()
120
+ expect(res.send).not.toHaveBeenCalled()
121
+ })
122
+
123
+ it('exposes type, status and message in the body outside production', async () => {
124
+ vi.stubEnv('NODE_ENV', 'development')
125
+ mockExecuteWithErrors([maskedError(new BadRequestError('invalid CEP'))])
126
+
127
+ const res = createResponse()
128
+ await handler(createRequest(), res)
129
+
130
+ const body = JSON.parse(
131
+ (res.send as ReturnType<typeof vi.fn>).mock.calls[0][0]
132
+ )
133
+ expect(body.errors[0]).toEqual({
134
+ extensions: { type: 'BadRequestError', status: 400 },
135
+ message: 'invalid CEP',
136
+ })
137
+ })
138
+
139
+ it('omits the free-text message from the body in production', async () => {
140
+ vi.stubEnv('NODE_ENV', 'production')
141
+ mockExecuteWithErrors([maskedError(new BadRequestError('invalid CEP'))])
142
+
143
+ const res = createResponse()
144
+ await handler(createRequest(), res)
145
+
146
+ const body = JSON.parse(
147
+ (res.send as ReturnType<typeof vi.fn>).mock.calls[0][0]
148
+ )
149
+ expect(body.errors[0]).toEqual({
150
+ extensions: { type: 'BadRequestError', status: 400 },
151
+ })
152
+ expect(body.errors[0].message).toBeUndefined()
153
+ })
154
+
155
+ it('uses the first recoverable FastStoreError when several errors exist', async () => {
156
+ mockExecuteWithErrors([
157
+ new GraphQLError('plain error'),
158
+ maskedError(new NotFoundError('missing')),
159
+ maskedError(new BadRequestError('bad')),
160
+ ])
161
+
162
+ const res = createResponse()
163
+ await handler(createRequest(), res)
164
+
165
+ expect(res.status).toHaveBeenCalledWith(404)
166
+ })
167
+ })