@open-mercato/sync-akeneo 0.9.1-develop.7553.1.8dbab05b5a → 0.9.1-develop.7567.1.b5e80ff620

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,3 +1,4 @@
1
+ import { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'
1
2
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
2
3
  import {
3
4
  FetchTimeoutError,
@@ -18,6 +19,17 @@ type TokenState = {
18
19
  expiresAt: number
19
20
  }
20
21
 
22
+ type AkeneoTokenPayload = {
23
+ access_token?: string
24
+ refresh_token?: string
25
+ expires_in?: number
26
+ }
27
+
28
+ type AkeneoAttemptOutcome<T> =
29
+ | { kind: 'done'; value: T }
30
+ | { kind: 'reauthenticate' }
31
+ | { kind: 'rate_limited'; retryAfter: string | null }
32
+
21
33
  type AkeneoListResponse<T> = {
22
34
  _embedded?: {
23
35
  items?: T[]
@@ -59,6 +71,20 @@ export class UnsafeAkeneoUrlError extends Error {
59
71
  }
60
72
  }
61
73
 
74
+ export class AkeneoResponseTooLargeError extends Error {
75
+ public readonly limitBytes: number
76
+
77
+ constructor(limitBytes: number) {
78
+ super(`Akeneo response exceeds the ${limitBytes} byte limit`)
79
+ this.name = 'AkeneoResponseTooLargeError'
80
+ this.limitBytes = limitBytes
81
+ }
82
+ }
83
+
84
+ export function isAkeneoResponseTooLargeError(error: unknown): error is AkeneoResponseTooLargeError {
85
+ return error instanceof Error && error.name === 'AkeneoResponseTooLargeError'
86
+ }
87
+
62
88
  type AkeneoClient = ReturnType<typeof createAkeneoClient>
63
89
 
64
90
  const AKENEO_URL_SUBJECT = 'Akeneo URL'
@@ -250,6 +276,8 @@ function sleep(ms: number): Promise<void> {
250
276
  const DEFAULT_AKENEO_REQUEST_TIMEOUT_MS = 30_000
251
277
  const DEFAULT_AKENEO_MAX_RATE_LIMIT_RETRIES = 5
252
278
  const DEFAULT_AKENEO_RETRY_AFTER_CAP_MS = 60_000
279
+ const DEFAULT_AKENEO_MAX_RESPONSE_BYTES = 32 * 1024 * 1024
280
+ const AKENEO_ERROR_BODY_MAX_BYTES = 64 * 1024
253
281
 
254
282
  function resolvePositiveIntEnv(raw: string | undefined, fallback: number): number {
255
283
  const parsed = Number.parseInt(raw ?? '', 10)
@@ -271,11 +299,90 @@ function clampAkeneoRetryAfterMs(retryAfterHeader: string | null, env: NodeJS.Pr
271
299
  return Math.min(Math.max(requestedMs, 0), cap)
272
300
  }
273
301
 
274
- async function safeAkeneoFetchWithTimeout(
302
+ function resolveAkeneoMaxResponseBytes(env: NodeJS.ProcessEnv = process.env): number {
303
+ return resolvePositiveIntEnv(env.OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES, DEFAULT_AKENEO_MAX_RESPONSE_BYTES)
304
+ }
305
+
306
+ function abortReasonError(signal: AbortSignal): Error {
307
+ return signal.reason instanceof Error ? signal.reason : new DOMException('Aborted', 'AbortError')
308
+ }
309
+
310
+ function settleBeforeAbort<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
311
+ if (signal.aborted) return Promise.reject(abortReasonError(signal))
312
+ return new Promise<T>((resolve, reject) => {
313
+ const onAbort = () => reject(abortReasonError(signal))
314
+ signal.addEventListener('abort', onAbort, { once: true })
315
+ pending.then(
316
+ (value) => {
317
+ signal.removeEventListener('abort', onAbort)
318
+ resolve(value)
319
+ },
320
+ (error: unknown) => {
321
+ signal.removeEventListener('abort', onAbort)
322
+ reject(error)
323
+ },
324
+ )
325
+ })
326
+ }
327
+
328
+ function discardAkeneoBody(response: Response): void {
329
+ void response.body?.cancel().catch(() => undefined)
330
+ }
331
+
332
+ async function readAkeneoBody(
333
+ response: Response,
334
+ options: { maxBytes: number; signal: AbortSignal; truncate?: boolean },
335
+ ): Promise<Buffer> {
336
+ const { maxBytes, signal, truncate = false } = options
337
+ const declaredLength = Number(response.headers.get('content-length') ?? '')
338
+ if (!truncate && Number.isFinite(declaredLength) && declaredLength > maxBytes) {
339
+ discardAkeneoBody(response)
340
+ throw new AkeneoResponseTooLargeError(maxBytes)
341
+ }
342
+ if (!response.body) return Buffer.alloc(0)
343
+
344
+ const reader = response.body.getReader()
345
+ const chunks: Uint8Array[] = []
346
+ let receivedBytes = 0
347
+ let finished = false
348
+ try {
349
+ while (true) {
350
+ const { done, value } = await settleBeforeAbort(reader.read(), signal)
351
+ if (done) {
352
+ finished = true
353
+ break
354
+ }
355
+ if (receivedBytes + value.byteLength > maxBytes) {
356
+ if (!truncate) throw new AkeneoResponseTooLargeError(maxBytes)
357
+ chunks.push(value.subarray(0, maxBytes - receivedBytes))
358
+ receivedBytes = maxBytes
359
+ break
360
+ }
361
+ chunks.push(value)
362
+ receivedBytes += value.byteLength
363
+ }
364
+ } finally {
365
+ if (!finished) void reader.cancel().catch(() => undefined)
366
+ }
367
+ return Buffer.concat(chunks, receivedBytes)
368
+ }
369
+
370
+ async function readAkeneoJson<T>(response: Response, signal: AbortSignal): Promise<T> {
371
+ const bytes = await readAkeneoBody(response, { maxBytes: resolveAkeneoMaxResponseBytes(), signal })
372
+ return JSON.parse(new TextDecoder().decode(bytes)) as T
373
+ }
374
+
375
+ async function readAkeneoErrorText(response: Response, signal: AbortSignal): Promise<string> {
376
+ const bytes = await readAkeneoBody(response, { maxBytes: AKENEO_ERROR_BODY_MAX_BYTES, signal, truncate: true })
377
+ return new TextDecoder().decode(bytes)
378
+ }
379
+
380
+ async function withAkeneoResponse<T>(
275
381
  input: string,
276
- init: FetchWithTimeoutInit = {},
277
- deps: AkeneoClientDeps = {},
278
- ): Promise<Response> {
382
+ init: FetchWithTimeoutInit,
383
+ deps: AkeneoClientDeps,
384
+ consume: (response: Response, signal: AbortSignal) => Promise<T>,
385
+ ): Promise<T> {
279
386
  const { timeoutMs, signal, ...rest } = init
280
387
  const effectiveTimeout = resolveTimeoutMs(timeoutMs)
281
388
  const controller = new AbortController()
@@ -296,13 +403,14 @@ async function safeAkeneoFetchWithTimeout(
296
403
  }
297
404
 
298
405
  try {
299
- return await safeOutboundFetch(input, { ...rest, signal: controller.signal }, {
406
+ const response = await safeOutboundFetch(input, { ...rest, signal: controller.signal }, {
300
407
  errorFactory: akeneoUrlErrorFactory,
301
408
  subject: AKENEO_URL_SUBJECT,
302
409
  allowPrivate: deps.allowPrivate ?? isAllowPrivateAkeneoUrlsEnabled(),
303
410
  lookupHost: deps.lookupHost,
304
411
  fetchImpl: deps.fetchImpl,
305
412
  })
413
+ return await consume(response, controller.signal)
306
414
  } catch (err) {
307
415
  if ((err as { name?: string } | null)?.name === 'AbortError') {
308
416
  const reason = (controller.signal as AbortSignal & { reason?: unknown }).reason
@@ -342,7 +450,7 @@ export function createAkeneoClient(credentialsInput: Record<string, unknown>, de
342
450
  }
343
451
 
344
452
  async function acquirePasswordGrantToken(): Promise<TokenState> {
345
- const response = await safeAkeneoFetchWithTimeout(tokenEndpointUrl, {
453
+ const payload = await withAkeneoResponse(tokenEndpointUrl, {
346
454
  method: 'POST',
347
455
  timeoutMs: resolveAkeneoRequestTimeoutMs(),
348
456
  redirect: 'manual',
@@ -357,18 +465,13 @@ export function createAkeneoClient(credentialsInput: Record<string, unknown>, de
357
465
  username: credentials.username,
358
466
  password: credentials.password,
359
467
  }),
360
- }, deps)
361
-
362
- if (!response.ok) {
363
- const message = await response.text()
364
- throw new Error(`Akeneo authentication failed (${response.status}): ${message}`)
365
- }
366
-
367
- const payload = await response.json() as {
368
- access_token?: string
369
- refresh_token?: string
370
- expires_in?: number
371
- }
468
+ }, deps, async (response, signal) => {
469
+ if (!response.ok) {
470
+ const message = await readAkeneoErrorText(response, signal)
471
+ throw new Error(`Akeneo authentication failed (${response.status}): ${message}`)
472
+ }
473
+ return readAkeneoJson<AkeneoTokenPayload>(response, signal)
474
+ })
372
475
  if (!payload.access_token) {
373
476
  throw new Error('Akeneo authentication did not return an access token')
374
477
  }
@@ -382,7 +485,7 @@ export function createAkeneoClient(credentialsInput: Record<string, unknown>, de
382
485
 
383
486
  async function refreshAccessToken(current: TokenState): Promise<TokenState> {
384
487
  if (!current.refreshToken) return acquirePasswordGrantToken()
385
- const response = await safeAkeneoFetchWithTimeout(tokenEndpointUrl, {
488
+ const payload = await withAkeneoResponse(tokenEndpointUrl, {
386
489
  method: 'POST',
387
490
  timeoutMs: resolveAkeneoRequestTimeoutMs(),
388
491
  redirect: 'manual',
@@ -396,18 +499,15 @@ export function createAkeneoClient(credentialsInput: Record<string, unknown>, de
396
499
  client_secret: credentials.clientSecret,
397
500
  refresh_token: current.refreshToken,
398
501
  }),
399
- }, deps)
400
-
401
- if (!response.ok) {
402
- return acquirePasswordGrantToken()
403
- }
502
+ }, deps, async (response, signal) => {
503
+ if (!response.ok) {
504
+ discardAkeneoBody(response)
505
+ return null
506
+ }
507
+ return readAkeneoJson<AkeneoTokenPayload>(response, signal)
508
+ })
404
509
 
405
- const payload = await response.json() as {
406
- access_token?: string
407
- refresh_token?: string
408
- expires_in?: number
409
- }
410
- if (!payload.access_token) return acquirePasswordGrantToken()
510
+ if (!payload?.access_token) return acquirePasswordGrantToken()
411
511
  return {
412
512
  accessToken: payload.access_token,
413
513
  refreshToken: payload.refresh_token ?? current.refreshToken ?? null,
@@ -423,100 +523,76 @@ export function createAkeneoClient(credentialsInput: Record<string, unknown>, de
423
523
  return tokenState.accessToken
424
524
  }
425
525
 
426
- async function request<T>(
526
+ async function sendAuthenticated<T>(
427
527
  pathOrUrl: string,
428
- init: RequestInit = {},
528
+ init: RequestInit,
529
+ defaultHeaders: Record<string, string>,
530
+ readSuccess: (response: Response, signal: AbortSignal) => Promise<T>,
429
531
  retried = false,
430
532
  rateLimitAttempts = 0,
431
533
  ): Promise<T> {
432
534
  const url = resolveAkeneoRequestUrl(pathOrUrl)
433
535
  const token = await ensureToken()
434
536
 
435
- const response = await safeAkeneoFetchWithTimeout(url, {
537
+ const outcome = await withAkeneoResponse<AkeneoAttemptOutcome<T>>(url, {
436
538
  ...init,
437
539
  timeoutMs: resolveAkeneoRequestTimeoutMs(),
438
540
  redirect: 'manual',
439
541
  headers: {
440
- accept: 'application/json',
542
+ ...defaultHeaders,
441
543
  authorization: `Bearer ${token}`,
442
544
  ...init.headers,
443
545
  },
444
- }, deps)
445
-
446
- if (response.status === 401 && !retried) {
447
- await ensureToken(true)
448
- return request<T>(pathOrUrl, init, true, rateLimitAttempts)
449
- }
450
-
451
- if (response.status === 429) {
452
- if (rateLimitAttempts >= resolveAkeneoMaxRateLimitRetries()) {
453
- const body = await response.text()
454
- throw new Error(`Akeneo request failed (429): ${body}`)
546
+ }, deps, async (response, signal) => {
547
+ if (response.status === 401 && !retried) {
548
+ discardAkeneoBody(response)
549
+ return { kind: 'reauthenticate' }
455
550
  }
456
- await sleep(clampAkeneoRetryAfterMs(response.headers.get('retry-after')))
457
- return request<T>(pathOrUrl, init, retried, rateLimitAttempts + 1)
458
- }
551
+ if (response.status === 429 && rateLimitAttempts < resolveAkeneoMaxRateLimitRetries()) {
552
+ discardAkeneoBody(response)
553
+ return { kind: 'rate_limited', retryAfter: response.headers.get('retry-after') }
554
+ }
555
+ if (!response.ok) {
556
+ const body = await readAkeneoErrorText(response, signal)
557
+ throw new Error(`Akeneo request failed (${response.status}): ${body}`)
558
+ }
559
+ return { kind: 'done', value: await readSuccess(response, signal) }
560
+ })
459
561
 
460
- if (!response.ok) {
461
- const body = await response.text()
462
- throw new Error(`Akeneo request failed (${response.status}): ${body}`)
562
+ if (outcome.kind === 'reauthenticate') {
563
+ await ensureToken(true)
564
+ return sendAuthenticated(pathOrUrl, init, defaultHeaders, readSuccess, true, rateLimitAttempts)
463
565
  }
464
-
465
- if (response.status === 204) {
466
- return null as T
566
+ if (outcome.kind === 'rate_limited') {
567
+ await sleep(clampAkeneoRetryAfterMs(outcome.retryAfter))
568
+ return sendAuthenticated(pathOrUrl, init, defaultHeaders, readSuccess, retried, rateLimitAttempts + 1)
467
569
  }
570
+ return outcome.value
571
+ }
468
572
 
469
- return response.json() as Promise<T>
573
+ async function request<T>(pathOrUrl: string, init: RequestInit = {}): Promise<T> {
574
+ return sendAuthenticated<T>(pathOrUrl, init, { accept: 'application/json' }, async (response, signal) => {
575
+ if (response.status === 204) {
576
+ discardAkeneoBody(response)
577
+ return null as T
578
+ }
579
+ return readAkeneoJson<T>(response, signal)
580
+ })
470
581
  }
471
582
 
472
- async function requestBinary(
473
- pathOrUrl: string,
474
- init: RequestInit = {},
475
- retried = false,
476
- rateLimitAttempts = 0,
477
- ): Promise<{
583
+ async function requestBinary(pathOrUrl: string, init: RequestInit = {}): Promise<{
478
584
  buffer: Buffer
479
585
  contentType: string | null
480
586
  contentLength: number | null
481
587
  }> {
482
- const url = resolveAkeneoRequestUrl(pathOrUrl)
483
- const token = await ensureToken()
484
-
485
- const response = await safeAkeneoFetchWithTimeout(url, {
486
- ...init,
487
- timeoutMs: resolveAkeneoRequestTimeoutMs(),
488
- redirect: 'manual',
489
- headers: {
490
- authorization: `Bearer ${token}`,
491
- ...init.headers,
492
- },
493
- }, deps)
494
-
495
- if (response.status === 401 && !retried) {
496
- await ensureToken(true)
497
- return requestBinary(pathOrUrl, init, true, rateLimitAttempts)
498
- }
499
-
500
- if (response.status === 429) {
501
- if (rateLimitAttempts >= resolveAkeneoMaxRateLimitRetries()) {
502
- const body = await response.text()
503
- throw new Error(`Akeneo request failed (429): ${body}`)
588
+ return sendAuthenticated(pathOrUrl, init, {}, async (response, signal) => {
589
+ const buffer = await readAkeneoBody(response, { maxBytes: resolveAttachmentMaxBytes(), signal })
590
+ return {
591
+ buffer,
592
+ contentType: response.headers.get('content-type'),
593
+ contentLength: buffer.byteLength,
504
594
  }
505
- await sleep(clampAkeneoRetryAfterMs(response.headers.get('retry-after')))
506
- return requestBinary(pathOrUrl, init, retried, rateLimitAttempts + 1)
507
- }
508
-
509
- if (!response.ok) {
510
- const body = await response.text()
511
- throw new Error(`Akeneo request failed (${response.status}): ${body}`)
512
- }
513
-
514
- const arrayBuffer = await response.arrayBuffer()
515
- return {
516
- buffer: Buffer.from(arrayBuffer),
517
- contentType: response.headers.get('content-type'),
518
- contentLength: Number(response.headers.get('content-length') ?? '') || arrayBuffer.byteLength || null,
519
- }
595
+ })
520
596
  }
521
597
 
522
598
  function buildUrl(path: string, params: Record<string, string | number | boolean | undefined | null>): string {