orionplace-react 0.7.5 → 0.7.6

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 CHANGED
@@ -480,7 +480,9 @@ authorization and cookies are forwarded. Backend statuses are retained and cooki
480
480
  Domain attributes are removed to bind cookies to the storefront. Gateway responses
481
481
  use `Cache-Control: no-store`, except public blog images and videos: they keep the
482
482
  backend's `private, max-age` rule, so the browser does not download them again on
483
- every page. Network failures return `502`.
483
+ every page. When the network drops the connection before the backend answers, GET,
484
+ HEAD and Application Load requests are sent once more on a new connection; other
485
+ requests are never repeated. Network failures return `502`.
484
486
 
485
487
  A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
486
488
  `403`. A request from the page's own host or the storefront domain passes, and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-react",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Next.js 16 storefront for your Orionplace marketplace: setup CLI, server API gateway, typed store and ready-made catalog, cart, checkout and account components in English and Russian, plus an Android app build.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -142,6 +142,74 @@ export function marketplaceConfigDirectory(root = process.cwd()) {
142
142
  : resolve(root, 'sitemaps');
143
143
  }
144
144
 
145
+ // The network can drop a pooled connection to the backend (a reset somewhere
146
+ // between this server and the API host). The request then fails before any
147
+ // answer arrives, so a request that is safe to repeat gets one more try on a
148
+ // new connection instead of a failed page.
149
+ const droppedConnection = new Set([
150
+ 'ECONNRESET',
151
+ 'ECONNABORTED',
152
+ 'EPIPE',
153
+ 'UND_ERR_SOCKET',
154
+ 'UND_ERR_CLOSED',
155
+ ]);
156
+ // Application Load only reads, and its body is a small JSON object; a larger
157
+ // body is streamed once without a retry.
158
+ const appLoadPath = /^\/api\/appload\/?$/;
159
+ const repeatBodyLimit = 64 * 1024;
160
+
161
+ /** @param {any} error */
162
+ function connectionDropped(error) {
163
+ return droppedConnection.has(error?.cause?.code ?? error?.code);
164
+ }
165
+
166
+ /**
167
+ * The upstream body and whether the request may be sent twice: GET and HEAD
168
+ * have no body; Application Load keeps its small body for a second try.
169
+ * @param {Request} request @param {string} pathname
170
+ * @returns {Promise<{body: any, repeatable: boolean}>}
171
+ */
172
+ async function upstreamBody(request, pathname) {
173
+ if (['GET', 'HEAD'].includes(request.method)) {
174
+ return { body: undefined, repeatable: true };
175
+ }
176
+ if (!request.body || !appLoadPath.test(pathname)) {
177
+ return { body: request.body, repeatable: false };
178
+ }
179
+ const reader = request.body.getReader();
180
+ const chunks = [];
181
+ let size = 0;
182
+ for (;;) {
183
+ const { done, value } = await reader.read();
184
+ if (done) {
185
+ return { body: Buffer.concat(chunks), repeatable: true };
186
+ }
187
+ chunks.push(value);
188
+ size += value.byteLength;
189
+ if (size > repeatBodyLimit) {
190
+ const body = new ReadableStream({
191
+ start(controller) {
192
+ for (const chunk of chunks) {
193
+ controller.enqueue(chunk);
194
+ }
195
+ },
196
+ async pull(controller) {
197
+ const next = await reader.read();
198
+ if (next.done) {
199
+ controller.close();
200
+ } else {
201
+ controller.enqueue(next.value);
202
+ }
203
+ },
204
+ cancel(reason) {
205
+ return reader.cancel(reason);
206
+ },
207
+ });
208
+ return { body, repeatable: false };
209
+ }
210
+ }
211
+ }
212
+
145
213
  /** @param {number} status @param {string} message */
146
214
  function failure(status, message) {
147
215
  return Response.json(
@@ -271,13 +339,23 @@ export async function forwardMarketplace(request, configuration = {}) {
271
339
  ]),
272
340
  duplex: 'half',
273
341
  };
274
- if (!['GET', 'HEAD'].includes(request.method)) {
275
- options.body = request.body;
276
- }
277
-
278
342
  let upstream;
279
343
  try {
280
- upstream = await fetch(target, options);
344
+ const { body, repeatable } = await upstreamBody(request, incoming.pathname);
345
+ if (body !== undefined) {
346
+ options.body = body;
347
+ }
348
+ try {
349
+ upstream = await fetch(target, options);
350
+ } catch (error) {
351
+ if (!repeatable || !connectionDropped(error) || options.signal.aborted) {
352
+ throw error;
353
+ }
354
+ console.warn(
355
+ `[orionplace] Backend connection dropped (${error?.cause?.code ?? error?.code}), retrying once`,
356
+ );
357
+ upstream = await fetch(target, options);
358
+ }
281
359
  } catch (error) {
282
360
  // The browser receives only the status; keep the cause in the server log.
283
361
  if (!request.signal.aborted) {