orionplace-nuxt 0.7.4 → 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
@@ -468,7 +468,9 @@ authorization and cookies are forwarded. Backend statuses are retained and cooki
468
468
  Domain attributes are removed to bind cookies to the storefront. Gateway responses
469
469
  use `Cache-Control: no-store`, except public blog images and videos: they keep the
470
470
  backend's `private, max-age` rule, so the browser does not download them again on
471
- every page. Network failures return `502`.
471
+ every page. When the network drops the connection before the backend answers, GET,
472
+ HEAD and Application Load requests are sent once more on a new connection; other
473
+ requests are never repeated. Network failures return `502`.
472
474
 
473
475
  A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
474
476
  `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-nuxt",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Nuxt 4 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) {
@@ -73,8 +73,21 @@ function createClients(): OrionplaceClients {
73
73
  return nativeAppRequest(appOrigin, path, request);
74
74
  }
75
75
  let received: OrionplaceApiResponse | undefined;
76
+ // A server render's own API call copies the visitor's request headers.
77
+ // A visitor who follows a link from another site arrives with
78
+ // `Sec-Fetch-Site: cross-site` (and a form post with a foreign Origin);
79
+ // the gateway's cross-site check would refuse the render's call and the
80
+ // page would answer 503. The call comes from this server, so it is sent
81
+ // as same-origin without the visitor's Origin.
76
82
  const headers = jar
77
- ? { ...request.headers, cookie: jar.header() }
83
+ ? {
84
+ ...request.headers,
85
+ cookie: jar.header(),
86
+ origin: '',
87
+ 'sec-fetch-site': 'same-origin',
88
+ 'sec-fetch-mode': 'cors',
89
+ 'sec-fetch-dest': 'empty',
90
+ }
78
91
  : request.headers;
79
92
 
80
93
  await requestFetch<unknown>(path, {
@@ -104,6 +104,8 @@ export interface ICustomerSubscription {
104
104
  cancelled_date: IApiDateTime | null;
105
105
  returned_amount: ICurrencyAmounts;
106
106
  c_tickets: number;
107
+ /** Partner code a trial remembered, while it waits for the first payment; fill the promo field with it on upgrade. */
108
+ referral_code: string | null;
107
109
  renew_period: ICustomerSubscriptionRenewal | null;
108
110
  plugin: {
109
111
  remote_subscription_last_sync: IApiDateTime | null;