@cloudpdf/engine 3.0.0-next.6 → 3.0.0-next.8

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/dist/index.d.cts CHANGED
@@ -21,6 +21,23 @@ interface HttpClientOptions {
21
21
  sessionId?: string;
22
22
  /** Replace the global fetch (e.g. in Node tests with undici). */
23
23
  fetch?: typeof globalThis.fetch;
24
+ /**
25
+ * Document affinity (opt-in; requires a server whose CORS
26
+ * allowlist includes the header): send `X-CloudPDF-Doc` on
27
+ * origin-bound doc requests so consistent-hash load balancers pin a
28
+ * document's traffic to one warm replica. Derived from the request
29
+ * path (`/v1/docs/:docId/…` — the same extraction the Helm chart's
30
+ * uri-mode fallback uses); never sent on CDN-rewritten requests.
31
+ */
32
+ docAffinityHeader?: boolean;
33
+ /** Observability: fired once per transport-level retry (engine busy /
34
+ * restarting). Lets apps count sheds without wrapping every call. */
35
+ onRetry?: (info: {
36
+ path: string;
37
+ code: string;
38
+ attempt: number;
39
+ waitMs: number;
40
+ }) => void;
24
41
  }
25
42
  /**
26
43
  * Per-document binding for CDN URL rewriting. `CloudDocumentHandle`
@@ -51,6 +68,11 @@ declare class HttpClient {
51
68
  private readonly base;
52
69
  private readonly tokenFn;
53
70
  private readonly fetchFn;
71
+ private readonly docAffinityHeader;
72
+ private readonly onRetry;
73
+ private static readonly MAX_RETRIES;
74
+ private static readonly RETRY_AFTER_CAP_MS;
75
+ private static readonly RETRY_AFTER_DEFAULT_MS;
54
76
  private readonly sessionId;
55
77
  private cdnBinding;
56
78
  constructor(opts: HttpClientOptions);
@@ -158,6 +180,7 @@ declare class HttpClient {
158
180
  postEmpty(path: string, body: unknown, signal: AbortSignal): Promise<void>;
159
181
  private requestJson;
160
182
  private request;
183
+ private execute;
161
184
  private parseJsonResponse;
162
185
  /**
163
186
  * Decide where this request actually goes. With no CDN binding (or
package/dist/index.d.ts CHANGED
@@ -21,6 +21,23 @@ interface HttpClientOptions {
21
21
  sessionId?: string;
22
22
  /** Replace the global fetch (e.g. in Node tests with undici). */
23
23
  fetch?: typeof globalThis.fetch;
24
+ /**
25
+ * Document affinity (opt-in; requires a server whose CORS
26
+ * allowlist includes the header): send `X-CloudPDF-Doc` on
27
+ * origin-bound doc requests so consistent-hash load balancers pin a
28
+ * document's traffic to one warm replica. Derived from the request
29
+ * path (`/v1/docs/:docId/…` — the same extraction the Helm chart's
30
+ * uri-mode fallback uses); never sent on CDN-rewritten requests.
31
+ */
32
+ docAffinityHeader?: boolean;
33
+ /** Observability: fired once per transport-level retry (engine busy /
34
+ * restarting). Lets apps count sheds without wrapping every call. */
35
+ onRetry?: (info: {
36
+ path: string;
37
+ code: string;
38
+ attempt: number;
39
+ waitMs: number;
40
+ }) => void;
24
41
  }
25
42
  /**
26
43
  * Per-document binding for CDN URL rewriting. `CloudDocumentHandle`
@@ -51,6 +68,11 @@ declare class HttpClient {
51
68
  private readonly base;
52
69
  private readonly tokenFn;
53
70
  private readonly fetchFn;
71
+ private readonly docAffinityHeader;
72
+ private readonly onRetry;
73
+ private static readonly MAX_RETRIES;
74
+ private static readonly RETRY_AFTER_CAP_MS;
75
+ private static readonly RETRY_AFTER_DEFAULT_MS;
54
76
  private readonly sessionId;
55
77
  private cdnBinding;
56
78
  constructor(opts: HttpClientOptions);
@@ -158,6 +180,7 @@ declare class HttpClient {
158
180
  postEmpty(path: string, body: unknown, signal: AbortSignal): Promise<void>;
159
181
  private requestJson;
160
182
  private request;
183
+ private execute;
161
184
  private parseJsonResponse;
162
185
  /**
163
186
  * Decide where this request actually goes. With no CDN binding (or
package/dist/index.js CHANGED
@@ -809,8 +809,7 @@ var CloudDocumentSecurityService = class {
809
809
  * and `applyCdnAccess` short-circuits to origin; safe to call always.
810
810
  */
811
811
  async postAccess(signal, body) {
812
- const response = await this.http.postJson(wirePaths.access, {
813
- docId: this.docId,
812
+ const response = await this.http.postJson(wirePaths.access(this.docId), {
814
813
  layerName: this.layerName,
815
814
  ...body.password ? { password: body.password } : {},
816
815
  mode: body.mode
@@ -1934,7 +1933,7 @@ function fallbackUnknownHead(id) {
1934
1933
  access: {
1935
1934
  required: true,
1936
1935
  reasons: ["permissions-unknown"],
1937
- endpoint: wirePaths.access
1936
+ endpoint: wirePaths.access(id)
1938
1937
  }
1939
1938
  };
1940
1939
  }
@@ -2042,6 +2041,8 @@ var HttpClient = class HttpClient {
2042
2041
  _defineProperty(this, "base", void 0);
2043
2042
  _defineProperty(this, "tokenFn", void 0);
2044
2043
  _defineProperty(this, "fetchFn", void 0);
2044
+ _defineProperty(this, "docAffinityHeader", void 0);
2045
+ _defineProperty(this, "onRetry", void 0);
2045
2046
  _defineProperty(this, "sessionId", void 0);
2046
2047
  _defineProperty(this, "cdnBinding", null);
2047
2048
  this.base = opts.baseUrl.replace(/\/$/, "");
@@ -2049,6 +2050,8 @@ var HttpClient = class HttpClient {
2049
2050
  this.tokenFn = token === void 0 ? null : typeof token === "function" ? token : () => token;
2050
2051
  this.sessionId = opts.sessionId ?? null;
2051
2052
  this.fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);
2053
+ this.docAffinityHeader = opts.docAffinityHeader ?? false;
2054
+ this.onRetry = opts.onRetry;
2052
2055
  }
2053
2056
  /** Normalized server base URL (no trailing slash). */
2054
2057
  get baseUrl() {
@@ -2325,7 +2328,7 @@ var HttpClient = class HttpClient {
2325
2328
  });
2326
2329
  }
2327
2330
  async request(path, init) {
2328
- const { url, extraHeader } = this.resolveOutgoing(path);
2331
+ const { url, extraHeader, routedToCdn } = this.resolveOutgoing(path);
2329
2332
  const headers = new Headers(init.headers ?? {});
2330
2333
  if (this.tokenFn) {
2331
2334
  const token = await this.tokenFn();
@@ -2334,11 +2337,32 @@ var HttpClient = class HttpClient {
2334
2337
  if (!headers.has("Accept")) headers.set("Accept", "application/json");
2335
2338
  if (this.sessionId) headers.set("X-Engine-Session-Id", this.sessionId);
2336
2339
  if (extraHeader) headers.set(extraHeader.name, extraHeader.value);
2337
- try {
2338
- return await this.fetchFn(url, {
2340
+ if (this.docAffinityHeader && !routedToCdn) {
2341
+ const doc = /^\/v1\/docs\/([^/]+)\//.exec(path);
2342
+ if (doc) headers.set("X-CloudPDF-Doc", decodeURIComponent(doc[1]));
2343
+ }
2344
+ for (let attempt = 0;; attempt++) {
2345
+ const res = await this.execute(url, {
2339
2346
  ...init,
2340
2347
  headers
2341
2348
  });
2349
+ if (res.status !== 503 || attempt >= HttpClient.MAX_RETRIES || isStreamBody(init.body ?? null)) return res;
2350
+ const code = await peekErrorCode(res);
2351
+ if (code === null || !RETRYABLE_503.has(code)) return res;
2352
+ const hinted = Number(res.headers.get("retry-after"));
2353
+ const waitMs = (Number.isFinite(hinted) ? Math.min(Math.max(hinted, 0) * 1e3, HttpClient.RETRY_AFTER_CAP_MS) : HttpClient.RETRY_AFTER_DEFAULT_MS) * (.9 + .2 * Math.random());
2354
+ this.onRetry?.({
2355
+ path,
2356
+ code,
2357
+ attempt,
2358
+ waitMs
2359
+ });
2360
+ await sleepRacingAbort(waitMs, init.signal ?? null);
2361
+ }
2362
+ }
2363
+ async execute(url, init) {
2364
+ try {
2365
+ return await this.fetchFn(url, init);
2342
2366
  } catch (err) {
2343
2367
  if (init.signal?.aborted) throw new AbortError$1(init.signal.reason ?? err);
2344
2368
  if (err?.name === "AbortError") throw new AbortError$1(err.message);
@@ -2364,7 +2388,8 @@ var HttpClient = class HttpClient {
2364
2388
  resolveOutgoing(path) {
2365
2389
  if (!this.cdnBinding || !this.cdnBinding.cdn) return {
2366
2390
  url: `${this.baseUrl}${path}`,
2367
- extraHeader: null
2391
+ extraHeader: null,
2392
+ routedToCdn: false
2368
2393
  };
2369
2394
  const applied = applyCdnAccess({
2370
2395
  path,
@@ -2375,7 +2400,8 @@ var HttpClient = class HttpClient {
2375
2400
  });
2376
2401
  return {
2377
2402
  url: applied.url,
2378
- extraHeader: applied.routedToCdn && applied.authHeader ? applied.authHeader : null
2403
+ extraHeader: applied.routedToCdn && applied.authHeader ? applied.authHeader : null,
2404
+ routedToCdn: applied.routedToCdn
2379
2405
  };
2380
2406
  }
2381
2407
  async throwFromBody(res) {
@@ -2395,6 +2421,9 @@ var HttpClient = class HttpClient {
2395
2421
  throw new EngineError$1(code, `HTTP ${res.status}`, { details: { status: res.status } });
2396
2422
  }
2397
2423
  };
2424
+ _defineProperty(HttpClient, "MAX_RETRIES", 2);
2425
+ _defineProperty(HttpClient, "RETRY_AFTER_CAP_MS", 5e3);
2426
+ _defineProperty(HttpClient, "RETRY_AFTER_DEFAULT_MS", 1e3);
2398
2427
  function mapStatusToCode(status) {
2399
2428
  if (status === 401) return EngineErrorCode$1.Unauthenticated;
2400
2429
  if (status === 403) return EngineErrorCode$1.Forbidden;
@@ -2409,6 +2438,36 @@ function trySerialized(value) {
2409
2438
  const result = EngineErrorPayloadSchema.safeParse(value);
2410
2439
  return result.success ? result.data : null;
2411
2440
  }
2441
+ const RETRYABLE_503 = /* @__PURE__ */ new Set(["EngineBusy", "EngineRestarting"]);
2442
+ function isStreamBody(body) {
2443
+ return typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
2444
+ }
2445
+ /** Read the error code from a 503 body without consuming the response. */
2446
+ async function peekErrorCode(res) {
2447
+ try {
2448
+ const code = (await res.clone().json())?.error?.code;
2449
+ return typeof code === "string" ? code : null;
2450
+ } catch {
2451
+ return null;
2452
+ }
2453
+ }
2454
+ function sleepRacingAbort(ms, signal) {
2455
+ return new Promise((resolve, reject) => {
2456
+ if (signal?.aborted) {
2457
+ reject(new AbortError$1(signal.reason ?? "aborted"));
2458
+ return;
2459
+ }
2460
+ const timer = setTimeout(() => {
2461
+ signal?.removeEventListener("abort", onAbort);
2462
+ resolve();
2463
+ }, ms);
2464
+ const onAbort = () => {
2465
+ clearTimeout(timer);
2466
+ reject(new AbortError$1(signal?.reason ?? "aborted"));
2467
+ };
2468
+ signal?.addEventListener("abort", onAbort, { once: true });
2469
+ });
2470
+ }
2412
2471
  //#endregion
2413
2472
  //#region src/CloudEngine.ts
2414
2473
  /**