@37signals/basecamp 0.11.0 → 0.13.0
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 +112 -16
- package/dist/.build-complete +0 -0
- package/dist/client.d.ts +65 -11
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +235 -215
- package/dist/client.js.map +1 -1
- package/dist/download.d.ts +7 -0
- package/dist/download.d.ts.map +1 -1
- package/dist/download.js +82 -22
- package/dist/download.js.map +1 -1
- package/dist/errors.d.ts +19 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +128 -15
- package/dist/errors.js.map +1 -1
- package/dist/generated/metadata.d.ts.map +1 -1
- package/dist/generated/metadata.js +443 -133
- package/dist/generated/metadata.js.map +1 -1
- package/dist/generated/metadata.ts +443 -133
- package/dist/generated/openapi-stripped.json +8999 -4907
- package/dist/generated/path-mapping.d.ts.map +1 -1
- package/dist/generated/path-mapping.js +40 -16
- package/dist/generated/path-mapping.js.map +1 -1
- package/dist/generated/path-mapping.ts +40 -16
- package/dist/generated/schema.d.ts +5508 -2346
- package/dist/generated/services/bookmarks.d.ts +76 -0
- package/dist/generated/services/bookmarks.d.ts.map +1 -0
- package/dist/generated/services/bookmarks.js +114 -0
- package/dist/generated/services/bookmarks.js.map +1 -0
- package/dist/generated/services/bookmarks.ts +158 -0
- package/dist/generated/services/boosts.d.ts +10 -0
- package/dist/generated/services/boosts.d.ts.map +1 -1
- package/dist/generated/services/boosts.js +8 -0
- package/dist/generated/services/boosts.js.map +1 -1
- package/dist/generated/services/boosts.ts +12 -0
- package/dist/generated/services/calendars.d.ts +47 -0
- package/dist/generated/services/calendars.d.ts.map +1 -0
- package/dist/generated/services/calendars.js +73 -0
- package/dist/generated/services/calendars.js.map +1 -0
- package/dist/generated/services/calendars.ts +102 -0
- package/dist/generated/services/campfires.d.ts +54 -40
- package/dist/generated/services/campfires.d.ts.map +1 -1
- package/dist/generated/services/campfires.js +82 -65
- package/dist/generated/services/campfires.js.map +1 -1
- package/dist/generated/services/campfires.ts +107 -85
- package/dist/generated/services/cards.d.ts +5 -0
- package/dist/generated/services/cards.d.ts.map +1 -1
- package/dist/generated/services/cards.js +4 -0
- package/dist/generated/services/cards.js.map +1 -1
- package/dist/generated/services/cards.ts +6 -0
- package/dist/generated/services/checkins.d.ts +14 -0
- package/dist/generated/services/checkins.d.ts.map +1 -1
- package/dist/generated/services/checkins.js +14 -1
- package/dist/generated/services/checkins.js.map +1 -1
- package/dist/generated/services/checkins.ts +20 -0
- package/dist/generated/services/client-approvals.d.ts +6 -3
- package/dist/generated/services/client-approvals.d.ts.map +1 -1
- package/dist/generated/services/client-approvals.js +8 -5
- package/dist/generated/services/client-approvals.js.map +1 -1
- package/dist/generated/services/client-approvals.ts +10 -5
- package/dist/generated/services/client-correspondences.d.ts +6 -3
- package/dist/generated/services/client-correspondences.d.ts.map +1 -1
- package/dist/generated/services/client-correspondences.js +8 -5
- package/dist/generated/services/client-correspondences.js.map +1 -1
- package/dist/generated/services/client-correspondences.ts +10 -5
- package/dist/generated/services/client-replies.d.ts +11 -4
- package/dist/generated/services/client-replies.d.ts.map +1 -1
- package/dist/generated/services/client-replies.js +16 -8
- package/dist/generated/services/client-replies.js.map +1 -1
- package/dist/generated/services/client-replies.ts +18 -8
- package/dist/generated/services/cloud-files.d.ts +92 -0
- package/dist/generated/services/cloud-files.d.ts.map +1 -0
- package/dist/generated/services/cloud-files.js +121 -0
- package/dist/generated/services/cloud-files.js.map +1 -0
- package/dist/generated/services/cloud-files.ts +188 -0
- package/dist/generated/services/comments.d.ts +5 -0
- package/dist/generated/services/comments.d.ts.map +1 -1
- package/dist/generated/services/comments.js +4 -0
- package/dist/generated/services/comments.js.map +1 -1
- package/dist/generated/services/comments.ts +6 -0
- package/dist/generated/services/documents.d.ts +12 -7
- package/dist/generated/services/documents.d.ts.map +1 -1
- package/dist/generated/services/documents.js +10 -6
- package/dist/generated/services/documents.js.map +1 -1
- package/dist/generated/services/documents.ts +14 -8
- package/dist/generated/services/drafts.d.ts +38 -0
- package/dist/generated/services/drafts.d.ts.map +1 -0
- package/dist/generated/services/drafts.js +40 -0
- package/dist/generated/services/drafts.js.map +1 -0
- package/dist/generated/services/drafts.ts +67 -0
- package/dist/generated/services/events.d.ts +5 -0
- package/dist/generated/services/events.d.ts.map +1 -1
- package/dist/generated/services/events.js +4 -0
- package/dist/generated/services/events.js.map +1 -1
- package/dist/generated/services/events.ts +6 -0
- package/dist/generated/services/everything.d.ts +83 -16
- package/dist/generated/services/everything.d.ts.map +1 -1
- package/dist/generated/services/everything.js +23 -13
- package/dist/generated/services/everything.js.map +1 -1
- package/dist/generated/services/everything.ts +100 -25
- package/dist/generated/services/folders.d.ts +96 -0
- package/dist/generated/services/folders.d.ts.map +1 -0
- package/dist/generated/services/folders.js +140 -0
- package/dist/generated/services/folders.js.map +1 -0
- package/dist/generated/services/folders.ts +201 -0
- package/dist/generated/services/forwards.d.ts +7 -20
- package/dist/generated/services/forwards.d.ts.map +1 -1
- package/dist/generated/services/forwards.js +6 -35
- package/dist/generated/services/forwards.js.map +1 -1
- package/dist/generated/services/forwards.ts +10 -48
- package/dist/generated/services/gauges.d.ts +7 -0
- package/dist/generated/services/gauges.d.ts.map +1 -1
- package/dist/generated/services/gauges.js +5 -1
- package/dist/generated/services/gauges.js.map +1 -1
- package/dist/generated/services/gauges.ts +9 -1
- package/dist/generated/services/google-documents.d.ts +98 -0
- package/dist/generated/services/google-documents.d.ts.map +1 -0
- package/dist/generated/services/google-documents.js +123 -0
- package/dist/generated/services/google-documents.js.map +1 -0
- package/dist/generated/services/google-documents.ts +196 -0
- package/dist/generated/services/index.d.ts +12 -5
- package/dist/generated/services/index.d.ts.map +1 -1
- package/dist/generated/services/index.js +12 -5
- package/dist/generated/services/index.js.map +1 -1
- package/dist/generated/services/index.ts +12 -5
- package/dist/generated/services/messages.d.ts +2 -0
- package/dist/generated/services/messages.d.ts.map +1 -1
- package/dist/generated/services/messages.js +1 -1
- package/dist/generated/services/messages.js.map +1 -1
- package/dist/generated/services/messages.ts +3 -1
- package/dist/generated/services/my-assignments.d.ts +52 -0
- package/dist/generated/services/my-assignments.d.ts.map +1 -1
- package/dist/generated/services/my-assignments.js +71 -0
- package/dist/generated/services/my-assignments.js.map +1 -1
- package/dist/generated/services/my-assignments.ts +104 -0
- package/dist/generated/services/my-notes.d.ts +45 -0
- package/dist/generated/services/my-notes.d.ts.map +1 -0
- package/dist/generated/services/my-notes.js +62 -0
- package/dist/generated/services/my-notes.js.map +1 -0
- package/dist/generated/services/my-notes.ts +92 -0
- package/dist/generated/services/my-notifications.d.ts +4 -2
- package/dist/generated/services/my-notifications.d.ts.map +1 -1
- package/dist/generated/services/my-notifications.js.map +1 -1
- package/dist/generated/services/my-notifications.ts +4 -2
- package/dist/generated/services/people.d.ts +11 -1
- package/dist/generated/services/people.d.ts.map +1 -1
- package/dist/generated/services/people.js +13 -2
- package/dist/generated/services/people.js.map +1 -1
- package/dist/generated/services/people.ts +15 -1
- package/dist/generated/services/projects.d.ts +26 -0
- package/dist/generated/services/projects.d.ts.map +1 -1
- package/dist/generated/services/projects.js +49 -1
- package/dist/generated/services/projects.js.map +1 -1
- package/dist/generated/services/projects.ts +61 -1
- package/dist/generated/services/recordings.d.ts +2 -12
- package/dist/generated/services/recordings.d.ts.map +1 -1
- package/dist/generated/services/recordings.js +1 -26
- package/dist/generated/services/recordings.js.map +1 -1
- package/dist/generated/services/recordings.ts +3 -31
- package/dist/generated/services/reports.d.ts +8 -12
- package/dist/generated/services/reports.d.ts.map +1 -1
- package/dist/generated/services/reports.js +11 -5
- package/dist/generated/services/reports.js.map +1 -1
- package/dist/generated/services/reports.ts +13 -14
- package/dist/generated/services/schedules.d.ts +94 -15
- package/dist/generated/services/schedules.d.ts.map +1 -1
- package/dist/generated/services/schedules.js +19 -8
- package/dist/generated/services/schedules.js.map +1 -1
- package/dist/generated/services/schedules.ts +107 -17
- package/dist/generated/services/search.d.ts +2 -0
- package/dist/generated/services/search.d.ts.map +1 -1
- package/dist/generated/services/search.js +1 -1
- package/dist/generated/services/search.js.map +1 -1
- package/dist/generated/services/search.ts +3 -1
- package/dist/generated/services/templates.d.ts +2 -0
- package/dist/generated/services/templates.d.ts.map +1 -1
- package/dist/generated/services/templates.js +1 -1
- package/dist/generated/services/templates.js.map +1 -1
- package/dist/generated/services/templates.ts +3 -1
- package/dist/generated/services/timeline.d.ts +2 -0
- package/dist/generated/services/timeline.d.ts.map +1 -1
- package/dist/generated/services/timeline.js +1 -0
- package/dist/generated/services/timeline.js.map +1 -1
- package/dist/generated/services/timeline.ts +3 -0
- package/dist/generated/services/timesheets.d.ts +16 -0
- package/dist/generated/services/timesheets.d.ts.map +1 -1
- package/dist/generated/services/timesheets.js +26 -2
- package/dist/generated/services/timesheets.js.map +1 -1
- package/dist/generated/services/timesheets.ts +35 -2
- package/dist/generated/services/todolist-groups.d.ts +11 -6
- package/dist/generated/services/todolist-groups.d.ts.map +1 -1
- package/dist/generated/services/todolist-groups.js +7 -3
- package/dist/generated/services/todolist-groups.js.map +1 -1
- package/dist/generated/services/todolist-groups.ts +13 -7
- package/dist/generated/services/todolists.d.ts +15 -13
- package/dist/generated/services/todolists.d.ts.map +1 -1
- package/dist/generated/services/todolists.js +11 -8
- package/dist/generated/services/todolists.js.map +1 -1
- package/dist/generated/services/todolists.ts +19 -14
- package/dist/generated/services/todos.d.ts +35 -12
- package/dist/generated/services/todos.d.ts.map +1 -1
- package/dist/generated/services/todos.js +47 -25
- package/dist/generated/services/todos.js.map +1 -1
- package/dist/generated/services/todos.ts +74 -30
- package/dist/generated/services/uploads.d.ts +5 -0
- package/dist/generated/services/uploads.d.ts.map +1 -1
- package/dist/generated/services/uploads.js +4 -0
- package/dist/generated/services/uploads.js.map +1 -1
- package/dist/generated/services/uploads.ts +6 -0
- package/dist/generated/services/vaults.d.ts +5 -0
- package/dist/generated/services/vaults.d.ts.map +1 -1
- package/dist/generated/services/vaults.js +4 -0
- package/dist/generated/services/vaults.js.map +1 -1
- package/dist/generated/services/vaults.ts +6 -0
- package/dist/hooks/otel.d.ts +1 -1
- package/dist/hooks/otel.js +1 -1
- package/dist/index.d.ts +21 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -9
- package/dist/index.js.map +1 -1
- package/dist/oauth/discovery.d.ts.map +1 -1
- package/dist/oauth/discovery.js +1 -0
- package/dist/oauth/discovery.js.map +1 -1
- package/dist/pagination-utils.d.ts +46 -0
- package/dist/pagination-utils.d.ts.map +1 -1
- package/dist/pagination-utils.js +85 -2
- package/dist/pagination-utils.js.map +1 -1
- package/dist/pagination.d.ts +12 -1
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +1 -1
- package/dist/pagination.js.map +1 -1
- package/dist/retry.d.ts +110 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +253 -0
- package/dist/retry.js.map +1 -0
- package/dist/services/base.d.ts.map +1 -1
- package/dist/services/base.js +59 -5
- package/dist/services/base.js.map +1 -1
- package/dist/services/cards-extensions.d.ts +26 -21
- package/dist/services/cards-extensions.d.ts.map +1 -1
- package/dist/services/cards-extensions.js +28 -31
- package/dist/services/cards-extensions.js.map +1 -1
- package/dist/services/documents-extensions.d.ts +122 -0
- package/dist/services/documents-extensions.d.ts.map +1 -0
- package/dist/services/documents-extensions.js +125 -0
- package/dist/services/documents-extensions.js.map +1 -0
- package/dist/services/merge-safe.d.ts +167 -0
- package/dist/services/merge-safe.d.ts.map +1 -0
- package/dist/services/merge-safe.js +217 -0
- package/dist/services/merge-safe.js.map +1 -0
- package/dist/services/schedules-extensions.d.ts +266 -0
- package/dist/services/schedules-extensions.d.ts.map +1 -0
- package/dist/services/schedules-extensions.js +268 -0
- package/dist/services/schedules-extensions.js.map +1 -0
- package/dist/services/todolists-extensions.d.ts +110 -0
- package/dist/services/todolists-extensions.d.ts.map +1 -0
- package/dist/services/todolists-extensions.js +262 -0
- package/dist/services/todolists-extensions.js.map +1 -0
- package/dist/services/todos-extensions.d.ts +12 -1
- package/dist/services/todos-extensions.d.ts.map +1 -1
- package/dist/services/todos-extensions.js +27 -8
- package/dist/services/todos-extensions.js.map +1 -1
- package/package.json +5 -4
- package/src/generated/metadata.ts +443 -133
- package/src/generated/openapi-stripped.json +8999 -4907
- package/src/generated/path-mapping.ts +40 -16
- package/src/generated/schema.d.ts +5508 -2346
- package/src/generated/services/bookmarks.ts +158 -0
- package/src/generated/services/boosts.ts +12 -0
- package/src/generated/services/calendars.ts +102 -0
- package/src/generated/services/campfires.ts +107 -85
- package/src/generated/services/cards.ts +6 -0
- package/src/generated/services/checkins.ts +20 -0
- package/src/generated/services/client-approvals.ts +10 -5
- package/src/generated/services/client-correspondences.ts +10 -5
- package/src/generated/services/client-replies.ts +18 -8
- package/src/generated/services/cloud-files.ts +188 -0
- package/src/generated/services/comments.ts +6 -0
- package/src/generated/services/documents.ts +14 -8
- package/src/generated/services/drafts.ts +67 -0
- package/src/generated/services/events.ts +6 -0
- package/src/generated/services/everything.ts +100 -25
- package/src/generated/services/folders.ts +201 -0
- package/src/generated/services/forwards.ts +10 -48
- package/src/generated/services/gauges.ts +9 -1
- package/src/generated/services/google-documents.ts +196 -0
- package/src/generated/services/index.ts +12 -5
- package/src/generated/services/messages.ts +3 -1
- package/src/generated/services/my-assignments.ts +104 -0
- package/src/generated/services/my-notes.ts +92 -0
- package/src/generated/services/my-notifications.ts +4 -2
- package/src/generated/services/people.ts +15 -1
- package/src/generated/services/projects.ts +61 -1
- package/src/generated/services/recordings.ts +3 -31
- package/src/generated/services/reports.ts +13 -14
- package/src/generated/services/schedules.ts +107 -17
- package/src/generated/services/search.ts +3 -1
- package/src/generated/services/templates.ts +3 -1
- package/src/generated/services/timeline.ts +3 -0
- package/src/generated/services/timesheets.ts +35 -2
- package/src/generated/services/todolist-groups.ts +13 -7
- package/src/generated/services/todolists.ts +19 -14
- package/src/generated/services/todos.ts +74 -30
- package/src/generated/services/uploads.ts +6 -0
- package/src/generated/services/vaults.ts +6 -0
package/dist/client.js
CHANGED
|
@@ -10,15 +10,16 @@ import metadata from "./generated/metadata.js";
|
|
|
10
10
|
import { PATH_TO_OPERATION } from "./generated/path-mapping.js";
|
|
11
11
|
import { BasecampError } from "./errors.js";
|
|
12
12
|
import { isLocalhost, requireSameOrigin } from "./security.js";
|
|
13
|
-
import { parseNextLink, resolveURL, isSameOrigin } from "./pagination-utils.js";
|
|
13
|
+
import { parseNextLink, resolveURL, isSameOrigin, DEFAULT_MAX_PAGES, assertValidMaxPages } from "./pagination-utils.js";
|
|
14
14
|
import { bearerAuth } from "./auth-strategy.js";
|
|
15
15
|
import { createDownloadURL } from "./download.js";
|
|
16
|
+
import { DEFAULT_RETRY_CONFIG, NO_RETRY_CONFIG, TerminalRetryError, executeWithRetry, } from "./retry.js";
|
|
16
17
|
// ============================================================================
|
|
17
18
|
// Services - Generated from OpenAPI spec (spec-driven, not hand-written)
|
|
18
19
|
// ============================================================================
|
|
19
20
|
import { ProjectsService } from "./generated/services/projects.js";
|
|
20
21
|
import { TodosService } from "./services/todos-extensions.js";
|
|
21
|
-
import { TodolistsService } from "./
|
|
22
|
+
import { TodolistsService } from "./services/todolists-extensions.js";
|
|
22
23
|
import { TodosetsService } from "./generated/services/todosets.js";
|
|
23
24
|
import { HillChartsService } from "./generated/services/hill-charts.js";
|
|
24
25
|
import { PeopleService } from "./generated/services/people.js";
|
|
@@ -38,12 +39,19 @@ import { ClientApprovalsService } from "./generated/services/client-approvals.js
|
|
|
38
39
|
import { ClientCorrespondencesService } from "./generated/services/client-correspondences.js";
|
|
39
40
|
import { ClientRepliesService } from "./generated/services/client-replies.js";
|
|
40
41
|
import { WebhooksService } from "./generated/services/webhooks.js";
|
|
42
|
+
import { BookmarksService } from "./generated/services/bookmarks.js";
|
|
43
|
+
import { FoldersService } from "./generated/services/folders.js";
|
|
44
|
+
import { DraftsService } from "./generated/services/drafts.js";
|
|
45
|
+
import { CalendarsService } from "./generated/services/calendars.js";
|
|
46
|
+
import { MyNotesService } from "./generated/services/my-notes.js";
|
|
41
47
|
import { SubscriptionsService } from "./generated/services/subscriptions.js";
|
|
42
48
|
import { AttachmentsService } from "./generated/services/attachments.js";
|
|
43
49
|
import { VaultsService } from "./generated/services/vaults.js";
|
|
44
|
-
import { DocumentsService } from "./
|
|
50
|
+
import { DocumentsService } from "./services/documents-extensions.js";
|
|
45
51
|
import { UploadsService } from "./services/uploads-extensions.js";
|
|
46
|
-
import {
|
|
52
|
+
import { CloudFilesService } from "./generated/services/cloud-files.js";
|
|
53
|
+
import { GoogleDocumentsService } from "./generated/services/google-documents.js";
|
|
54
|
+
import { SchedulesService } from "./services/schedules-extensions.js";
|
|
47
55
|
import { EventsService } from "./generated/services/events.js";
|
|
48
56
|
import { RecordingsService } from "./generated/services/recordings.js";
|
|
49
57
|
import { SearchService } from "./generated/services/search.js";
|
|
@@ -72,14 +80,14 @@ import { AuthorizationService } from "./services/authorization.js";
|
|
|
72
80
|
* a TimeoutOverflowWarning rather than honored.
|
|
73
81
|
*/
|
|
74
82
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
75
|
-
export const VERSION = "0.
|
|
76
|
-
export const API_VERSION = "2026-
|
|
83
|
+
export const VERSION = "0.13.0";
|
|
84
|
+
export const API_VERSION = "2026-08-05";
|
|
77
85
|
const DEFAULT_USER_AGENT = `basecamp-sdk-ts/${VERSION} (api:${API_VERSION})`;
|
|
78
86
|
/**
|
|
79
87
|
* Creates a type-safe Basecamp API client with built-in middleware for:
|
|
80
88
|
* - Authentication (Bearer token)
|
|
81
89
|
* - Retry with exponential backoff (respects Retry-After header)
|
|
82
|
-
* - ETag-based HTTP caching
|
|
90
|
+
* - ETag-based HTTP caching, opt-in via `enableCache` (defaults to false)
|
|
83
91
|
*
|
|
84
92
|
* @example
|
|
85
93
|
* ```ts
|
|
@@ -115,6 +123,24 @@ export function createBasecampClient(options) {
|
|
|
115
123
|
requestTimeoutMs > MAX_TIMEOUT_MS) {
|
|
116
124
|
throw new BasecampError("usage", `'requestTimeoutMs' must be an integer between 0 and ${MAX_TIMEOUT_MS}, got ${requestTimeoutMs}`);
|
|
117
125
|
}
|
|
126
|
+
// The cap the standalone helpers validate is the same cap the config carries,
|
|
127
|
+
// and this is the wider door: `maxPages` here is handed to every service and
|
|
128
|
+
// reached only later, inside `BaseService`'s `page < this.maxPages` loops, far
|
|
129
|
+
// from the call that misconfigured it. Same predicate, same error, checked
|
|
130
|
+
// once at construction — see assertValidMaxPages for what each rejected value
|
|
131
|
+
// does to the bound. Only when the caller supplied one: an absent cap must
|
|
132
|
+
// keep falling through to DEFAULT_MAX_PAGES.
|
|
133
|
+
//
|
|
134
|
+
// `!= null`, matching BaseService's guard exactly. This value is not consumed
|
|
135
|
+
// here — it is handed straight to every service below, where `maxPages ??
|
|
136
|
+
// DEFAULT_MAX_PAGES` treats an explicit `null` as absent. A `!== undefined`
|
|
137
|
+
// test here made the two doors disagree on that single value: the factory
|
|
138
|
+
// threw where the equivalent direct service construction defaulted. Whichever
|
|
139
|
+
// way "not supplied" is defined, both guards have to define it the same way,
|
|
140
|
+
// because the same `??` is downstream of both.
|
|
141
|
+
if (maxPages != null) {
|
|
142
|
+
assertValidMaxPages(maxPages);
|
|
143
|
+
}
|
|
118
144
|
const authStrategy = auth ?? bearerAuth(accessToken);
|
|
119
145
|
// Validate configuration (skip HTTPS check for localhost in dev/test)
|
|
120
146
|
if (baseUrl) {
|
|
@@ -130,28 +156,31 @@ export function createBasecampClient(options) {
|
|
|
130
156
|
throw new BasecampError("usage", `Invalid base URL: ${baseUrl}`);
|
|
131
157
|
}
|
|
132
158
|
}
|
|
133
|
-
|
|
134
|
-
//
|
|
159
|
+
// One lifecycle, shared between the retrying fetch and the lifecycle
|
|
160
|
+
// middleware: the fetch owns every attempt's start and each abandoned
|
|
161
|
+
// attempt's end; the middleware finalizes the terminal outcome.
|
|
162
|
+
const lifecycle = new RequestLifecycle(hooks);
|
|
163
|
+
// The retry loop lives BENEATH the middleware chain, as the client's custom
|
|
164
|
+
// fetch: openapi-fetch calls it exactly once per logical request, after every
|
|
165
|
+
// onRequest middleware and before every onError/onResponse. Installed
|
|
166
|
+
// unconditionally — with retry disabled it degenerates to a single attempt —
|
|
167
|
+
// so hook emission is single-sourced regardless of config.
|
|
168
|
+
const client = createClient({
|
|
169
|
+
baseUrl,
|
|
170
|
+
fetch: createRetryingFetch(lifecycle, authStrategy, enableRetry),
|
|
171
|
+
});
|
|
172
|
+
// Apply middleware in order: auth, lifecycle, cache.
|
|
135
173
|
// onRequest runs in this order; onResponse and onError run in reverse, so the
|
|
136
|
-
//
|
|
174
|
+
// lifecycle middleware finalizes the terminal attempt only after the cache
|
|
175
|
+
// middleware has had its chance to transform the response.
|
|
137
176
|
client.use(createAuthMiddleware(authStrategy, userAgent, requestTimeoutMs, baseUrl));
|
|
138
|
-
// One lifecycle, shared with the retry middleware: the retry loop lives inside a
|
|
139
|
-
// single middleware callback, so per-attempt hooks have to be emitted from there
|
|
140
|
-
// rather than by a middleware that is only visited once per request.
|
|
141
|
-
const lifecycle = new RequestLifecycle(hooks);
|
|
142
177
|
// Registered even when no hooks are configured. It emits nothing in that case
|
|
143
|
-
// (every call is optional-chained), but it
|
|
144
|
-
//
|
|
145
|
-
// listening, so skipping this would strand one map entry per retried request.
|
|
178
|
+
// (every call is optional-chained), but registering it unconditionally keeps
|
|
179
|
+
// the finalize path identical whether or not anyone is listening.
|
|
146
180
|
client.use(createLifecycleMiddleware(lifecycle));
|
|
147
181
|
if (enableCache) {
|
|
148
182
|
client.use(createCacheMiddleware());
|
|
149
183
|
}
|
|
150
|
-
// Registered last so that on the reverse-order response pass it runs first,
|
|
151
|
-
// before the cache and lifecycle middleware see the response.
|
|
152
|
-
if (enableRetry) {
|
|
153
|
-
client.use(createRetryMiddleware(lifecycle, authStrategy));
|
|
154
|
-
}
|
|
155
184
|
// Create enhanced client with additional properties
|
|
156
185
|
const enhancedClient = client;
|
|
157
186
|
Object.defineProperty(enhancedClient, "raw", {
|
|
@@ -207,7 +236,7 @@ export function createBasecampClient(options) {
|
|
|
207
236
|
// Wire downloadURL — raw fetch, not openapi-fetch (like fetchPage).
|
|
208
237
|
// Defined before service factories so UploadsService can inject it.
|
|
209
238
|
const downloadURLFn = createDownloadURL({
|
|
210
|
-
authStrategy, userAgent, baseUrl, hooks, requestTimeoutMs,
|
|
239
|
+
authStrategy, userAgent, baseUrl, hooks, requestTimeoutMs, enableRetry,
|
|
211
240
|
});
|
|
212
241
|
Object.defineProperty(enhancedClient, "downloadURL", {
|
|
213
242
|
value: downloadURLFn,
|
|
@@ -237,10 +266,17 @@ export function createBasecampClient(options) {
|
|
|
237
266
|
defineService("clientCorrespondences", () => new ClientCorrespondencesService(client, hooks, fetchPage, maxPages));
|
|
238
267
|
defineService("clientReplies", () => new ClientRepliesService(client, hooks, fetchPage, maxPages));
|
|
239
268
|
defineService("webhooks", () => new WebhooksService(client, hooks, fetchPage, maxPages));
|
|
269
|
+
defineService("bookmarks", () => new BookmarksService(client, hooks, fetchPage, maxPages));
|
|
270
|
+
defineService("folders", () => new FoldersService(client, hooks, fetchPage, maxPages));
|
|
271
|
+
defineService("drafts", () => new DraftsService(client, hooks, fetchPage, maxPages));
|
|
272
|
+
defineService("calendars", () => new CalendarsService(client, hooks, fetchPage, maxPages));
|
|
273
|
+
defineService("myNotes", () => new MyNotesService(client, hooks, fetchPage, maxPages));
|
|
240
274
|
defineService("subscriptions", () => new SubscriptionsService(client, hooks, fetchPage, maxPages));
|
|
241
275
|
defineService("attachments", () => new AttachmentsService(client, hooks, fetchPage, maxPages));
|
|
242
276
|
defineService("vaults", () => new VaultsService(client, hooks, fetchPage, maxPages));
|
|
243
277
|
defineService("documents", () => new DocumentsService(client, hooks, fetchPage, maxPages));
|
|
278
|
+
defineService("cloudFiles", () => new CloudFilesService(client, hooks, fetchPage, maxPages));
|
|
279
|
+
defineService("googleDocuments", () => new GoogleDocumentsService(client, hooks, fetchPage, maxPages));
|
|
244
280
|
defineService("uploads", () =>
|
|
245
281
|
// Positional args mirror BaseService (incl. authenticatedFetch/baseUrl slots the
|
|
246
282
|
// factory leaves unset), with downloadURLFn appended at the end.
|
|
@@ -301,45 +337,41 @@ function createAuthMiddleware(authStrategy, userAgent, requestTimeoutMs, baseUrl
|
|
|
301
337
|
};
|
|
302
338
|
}
|
|
303
339
|
/**
|
|
304
|
-
* Owns the onRequestStart / onRequestEnd / onRetry lifecycle for every attempt
|
|
305
|
-
* every in-flight request, keyed by openapi-fetch
|
|
340
|
+
* Owns the onRequestStart / onRequestEnd / onRetry lifecycle for every attempt
|
|
341
|
+
* of every in-flight request, keyed by the final Request object openapi-fetch
|
|
342
|
+
* hands to its custom fetch.
|
|
306
343
|
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
* middleware
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
* name was a collision.)
|
|
344
|
+
* openapi-fetch passes that same object to onResponse and onError, so identity
|
|
345
|
+
* holds from the retrying fetch (which begins every attempt) through to the
|
|
346
|
+
* lifecycle middleware (which finalizes the terminal one). Nothing ever goes on
|
|
347
|
+
* the wire — this keying replaced the old `X-SDK-Request-Id` and `X-Request-Id`
|
|
348
|
+
* correlation headers. (`X-Request-Id` in particular is a spec-reserved BC3
|
|
349
|
+
* *response* header; sending an SDK-internal value under that name was a
|
|
350
|
+
* collision.)
|
|
315
351
|
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
352
|
+
* A WeakMap makes the release bookkeeping structural: once a request settles
|
|
353
|
+
* and its Request is unreachable, the state is collectable — there is no
|
|
354
|
+
* release() to forget.
|
|
319
355
|
*/
|
|
320
356
|
class RequestLifecycle {
|
|
321
357
|
hooks;
|
|
322
|
-
states = new
|
|
358
|
+
states = new WeakMap();
|
|
323
359
|
constructor(hooks) {
|
|
324
360
|
this.hooks = hooks;
|
|
325
361
|
}
|
|
326
362
|
/** Begin an attempt and fire onRequestStart for it. */
|
|
327
|
-
begin(
|
|
328
|
-
this.states.set(
|
|
363
|
+
begin(request, method, url, attempt) {
|
|
364
|
+
this.states.set(request, { startTime: performance.now(), attempt, finalized: false });
|
|
329
365
|
this.emit(() => this.hooks?.onRequestStart?.({ method, url, attempt }));
|
|
330
366
|
}
|
|
331
|
-
/** The attempt currently in flight, or 1 if we have no record. */
|
|
332
|
-
attemptOf(id) {
|
|
333
|
-
return this.states.get(id)?.attempt ?? 1;
|
|
334
|
-
}
|
|
335
367
|
/**
|
|
336
368
|
* Fire onRequestEnd for the in-flight attempt, exactly once.
|
|
337
369
|
*
|
|
338
370
|
* A network failure or timeout carries statusCode 0 and the originating error,
|
|
339
371
|
* matching the multipart transport in services/base.ts and SPEC section 7.
|
|
340
372
|
*/
|
|
341
|
-
finalize(
|
|
342
|
-
const state = this.states.get(
|
|
373
|
+
finalize(request, method, url, outcome) {
|
|
374
|
+
const state = this.states.get(request);
|
|
343
375
|
if (!state || state.finalized)
|
|
344
376
|
return;
|
|
345
377
|
state.finalized = true;
|
|
@@ -359,13 +391,9 @@ class RequestLifecycle {
|
|
|
359
391
|
* just FAILED, while the standalone argument is the UPCOMING attempt. Go,
|
|
360
392
|
* Python, Ruby and Kotlin all pass (failed, failed + 1).
|
|
361
393
|
*/
|
|
362
|
-
retrying(
|
|
394
|
+
retrying(method, url, failedAttempt, error, delayMs) {
|
|
363
395
|
this.emit(() => this.hooks?.onRetry?.({ method, url, attempt: failedAttempt }, failedAttempt + 1, error, delayMs));
|
|
364
396
|
}
|
|
365
|
-
/** Drop all state for a logical request. Safe to call more than once. */
|
|
366
|
-
release(id) {
|
|
367
|
-
this.states.delete(id);
|
|
368
|
-
}
|
|
369
397
|
emit(fn) {
|
|
370
398
|
try {
|
|
371
399
|
fn();
|
|
@@ -377,37 +405,32 @@ class RequestLifecycle {
|
|
|
377
405
|
}
|
|
378
406
|
function createLifecycleMiddleware(lifecycle) {
|
|
379
407
|
return {
|
|
380
|
-
async
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
//
|
|
386
|
-
// single-attempt request, or attempt 2 after a retry — the retry middleware
|
|
387
|
-
// deliberately does not finalize a successful attempt, so that the cache
|
|
388
|
-
// middleware (which runs between the two) can transform the response first.
|
|
389
|
-
// finalize stays idempotent as a backstop.
|
|
408
|
+
async onResponse({ request, response }) {
|
|
409
|
+
// The authoritative end for the terminal attempt: the retrying fetch
|
|
410
|
+
// begins every attempt but deliberately does not finalize the response it
|
|
411
|
+
// returns, so that this pass can record the outcome the cache middleware
|
|
412
|
+
// has since transformed (a 304 rewritten into a cached 200). finalize
|
|
413
|
+
// stays idempotent as a backstop.
|
|
390
414
|
//
|
|
391
415
|
// fromCache means "served out of the ETag cache", and only the header the
|
|
392
416
|
// cache middleware sets proves that. A bare 304 does NOT: it reaches here
|
|
393
417
|
// when the cache is disabled, or is enabled but holds no entry for this key,
|
|
394
418
|
// and in both cases the caller's own conditional request went to the server.
|
|
395
419
|
const fromCache = response.headers.get("X-From-Cache") === "1";
|
|
396
|
-
lifecycle.finalize(
|
|
420
|
+
lifecycle.finalize(request, request.method, request.url, {
|
|
397
421
|
statusCode: response.status,
|
|
398
422
|
fromCache,
|
|
399
423
|
});
|
|
400
|
-
lifecycle.release(id);
|
|
401
424
|
return response;
|
|
402
425
|
},
|
|
403
|
-
async onError({ request, error
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
426
|
+
async onError({ request, error }) {
|
|
427
|
+
// A terminal throw from the retrying fetch — a network error, a timeout,
|
|
428
|
+
// or a retry's auth refresh — skips onResponse entirely, so this is the
|
|
429
|
+
// only place it can be observed.
|
|
430
|
+
lifecycle.finalize(request, request.method, request.url, {
|
|
407
431
|
statusCode: 0,
|
|
408
432
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
409
433
|
});
|
|
410
|
-
lifecycle.release(id);
|
|
411
434
|
// Returning nothing preserves the original error's identity and rethrows it.
|
|
412
435
|
return undefined;
|
|
413
436
|
},
|
|
@@ -523,21 +546,12 @@ function createCacheMiddleware() {
|
|
|
523
546
|
function getCacheKey(url, tokenHash) {
|
|
524
547
|
return `${tokenHash}:${url}`;
|
|
525
548
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
};
|
|
533
|
-
/** No-retry config for non-idempotent POST operations */
|
|
534
|
-
const NO_RETRY_CONFIG = {
|
|
535
|
-
maxAttempts: 1,
|
|
536
|
-
baseDelayMs: 0,
|
|
537
|
-
backoff: "constant",
|
|
538
|
-
retryOn: [],
|
|
539
|
-
};
|
|
540
|
-
const MAX_JITTER_MS = 100;
|
|
549
|
+
// =============================================================================
|
|
550
|
+
// Retrying Fetch (the retry loop, beneath the middleware chain)
|
|
551
|
+
// =============================================================================
|
|
552
|
+
// The retry loop itself lives in retry.ts (executeWithRetry) so the raw-fetch
|
|
553
|
+
// download hop 1 shares it; this section owns what is client-specific — the
|
|
554
|
+
// operation-metadata config resolution and the openapi-fetch integration.
|
|
541
555
|
// PATH_TO_OPERATION is imported from generated/path-mapping.js
|
|
542
556
|
/**
|
|
543
557
|
* Normalizes a URL path by replacing numeric IDs with placeholder tokens.
|
|
@@ -577,6 +591,8 @@ export function normalizeUrlPath(url) {
|
|
|
577
591
|
comments: "{commentId}",
|
|
578
592
|
tools: "{toolId}", // dock/tools/{toolId}
|
|
579
593
|
documents: "{documentId}",
|
|
594
|
+
cloud_files: "{cloudFileId}",
|
|
595
|
+
google_documents: "{googleDocumentId}",
|
|
580
596
|
inbox_forwards: "{forwardId}",
|
|
581
597
|
inboxes: "{inboxId}",
|
|
582
598
|
message_boards: "{boardId}",
|
|
@@ -679,148 +695,111 @@ function getRetryConfigForRequest(method, url) {
|
|
|
679
695
|
}
|
|
680
696
|
return DEFAULT_RETRY_CONFIG;
|
|
681
697
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
delay = calculateBackoffDelay(retryConfig, failedAttempt - 1);
|
|
731
|
-
}
|
|
732
|
-
const statusError = new Error(`HTTP ${response.status}: ${response.statusText || "Request failed"}`);
|
|
733
|
-
try {
|
|
734
|
-
// End the failed attempt before sleeping, so a slow backoff cannot leave
|
|
735
|
-
// an attempt open, then announce the upcoming one.
|
|
736
|
-
lifecycle.finalize(id, method, url, { statusCode: response.status });
|
|
737
|
-
lifecycle.retrying(id, method, url, failedAttempt, statusError, delay);
|
|
738
|
-
// This response is being discarded, so release its stream before we sleep
|
|
739
|
-
// rather than leaving it open across the backoff — otherwise a throttled
|
|
740
|
-
// client holds a connection per in-flight retry and cannot reuse any of
|
|
741
|
-
// them. The multipart transport in services/base.ts already does this.
|
|
742
|
-
// Errors are ignored: the body may already be consumed or closed.
|
|
743
|
-
void response.body?.cancel().catch(() => { });
|
|
744
|
-
await sleep(delay);
|
|
745
|
-
// The raw fetch below bypasses the middleware chain, so no downstream
|
|
746
|
-
// middleware sees this attempt begin — start it here.
|
|
747
|
-
//
|
|
748
|
-
// Started after the backoff but before any work that can throw. After, so
|
|
749
|
-
// the attempt's duration measures the request rather than the sleep;
|
|
750
|
-
// before, so that if the auth refresh or the fetch throws, the catch has a
|
|
751
|
-
// live attempt to finalize. Starting it later would let onRetry announce
|
|
752
|
-
// attempt 2 and then never account for it.
|
|
753
|
-
lifecycle.begin(id, method, url, failedAttempt + 1);
|
|
754
|
-
const body = bodyCache.get(id) ?? null;
|
|
755
|
-
const retryRequest = new Request(url, {
|
|
698
|
+
/**
|
|
699
|
+
* The retry loop, installed beneath the middleware chain as the client's custom
|
|
700
|
+
* fetch (createClient's `fetch` option). openapi-fetch calls it exactly once
|
|
701
|
+
* per logical request, after every onRequest middleware and before every
|
|
702
|
+
* onError/onResponse — so the loop runs to the operation's full declared
|
|
703
|
+
* maxAttempts, and no attempt ever leaves the chain, because the chain sits
|
|
704
|
+
* above the loop.
|
|
705
|
+
*
|
|
706
|
+
* Division of labor with the lifecycle middleware: the loop begins EVERY
|
|
707
|
+
* attempt and finalizes the attempts it abandons (before the backoff sleep);
|
|
708
|
+
* the terminal outcome — the response it returns, or the error it throws — is
|
|
709
|
+
* deliberately NOT finalized here. It flows up through the cache middleware,
|
|
710
|
+
* which may rewrite a 304 into a cached 200 + X-From-Cache, to the lifecycle
|
|
711
|
+
* middleware, which records the post-transform result. Finalizing in the loop
|
|
712
|
+
* would freeze the pre-transformation status, and the idempotent finalize
|
|
713
|
+
* would keep the hooks pass from correcting it.
|
|
714
|
+
*
|
|
715
|
+
* With retry disabled the loop degenerates to a single attempt but still owns
|
|
716
|
+
* hook emission, so start/end events are single-sourced regardless of config.
|
|
717
|
+
*/
|
|
718
|
+
function createRetryingFetch(lifecycle, authStrategy, enableRetry) {
|
|
719
|
+
return async (request) => {
|
|
720
|
+
const { method, url } = request;
|
|
721
|
+
const retryConfig = getRetryConfigForRequest(method, url);
|
|
722
|
+
const effectiveConfig = {
|
|
723
|
+
...retryConfig,
|
|
724
|
+
maxAttempts: enableRetry ? retryConfig.maxAttempts : 1,
|
|
725
|
+
};
|
|
726
|
+
// Serialize the body once, before the first send, because Request.body is
|
|
727
|
+
// a stream that can only be consumed once and a retry needs to replay it.
|
|
728
|
+
// clone() tees the stream, so attempt 1 sends the original untouched and
|
|
729
|
+
// replays read from this buffer. Requests that can never retry — including
|
|
730
|
+
// every non-idempotent POST, via NO_RETRY_CONFIG — skip the buffering.
|
|
731
|
+
const upperMethod = method.toUpperCase();
|
|
732
|
+
let bodyBuffer = null;
|
|
733
|
+
if (effectiveConfig.maxAttempts > 1 &&
|
|
734
|
+
(upperMethod === "POST" || upperMethod === "PUT" || upperMethod === "PATCH") &&
|
|
735
|
+
request.body) {
|
|
736
|
+
bodyBuffer = await request.clone().arrayBuffer();
|
|
737
|
+
}
|
|
738
|
+
let attemptRequest = request;
|
|
739
|
+
const makeAttempt = async (attempt) => {
|
|
740
|
+
if (attempt > 1) {
|
|
741
|
+
// Rebuild from the original request: the headers carry everything the
|
|
742
|
+
// onRequest middleware attached (auth, User-Agent, If-None-Match), and
|
|
743
|
+
// the signal keeps the caller's cancellation and the per-request
|
|
744
|
+
// timeout — one budget shared by all attempts and backoffs.
|
|
745
|
+
attemptRequest = new Request(url, {
|
|
756
746
|
method,
|
|
757
747
|
headers: new Headers(request.headers),
|
|
758
|
-
body,
|
|
748
|
+
body: bodyBuffer,
|
|
759
749
|
signal: request.signal,
|
|
760
750
|
});
|
|
761
|
-
// Refresh auth
|
|
762
|
-
|
|
763
|
-
|
|
751
|
+
// Refresh auth (the token may have rotated since the last attempt).
|
|
752
|
+
// The attempt is already begun, so a throwing refresh lands on a live
|
|
753
|
+
// attempt; the terminal marker carries it raw past the loop's retry
|
|
754
|
+
// classification to the lifecycle middleware's onError.
|
|
755
|
+
try {
|
|
756
|
+
await authStrategy.authenticate(attemptRequest.headers);
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
throw new TerminalRetryError(error);
|
|
764
760
|
}
|
|
765
|
-
// Deliberately NOT finalized here. The returned response still flows
|
|
766
|
-
// through the cache middleware, which may rewrite a 304 into a cached 200;
|
|
767
|
-
// finalizing now would freeze the pre-transformation status and
|
|
768
|
-
// fromCache: false, and an idempotent finalize means the hooks pass could
|
|
769
|
-
// not correct it. The lifecycle middleware ends this attempt instead, reading
|
|
770
|
-
// attempt 2 from the state begun above.
|
|
771
|
-
return await fetch(retryRequest);
|
|
772
|
-
}
|
|
773
|
-
catch (error) {
|
|
774
|
-
// openapi-fetch routes only the INITIAL fetch through onError, so anything
|
|
775
|
-
// thrown in here — the retry fetch, the auth refresh, the sleep — would
|
|
776
|
-
// otherwise escape with the attempt still open and its state stranded.
|
|
777
|
-
lifecycle.finalize(id, method, url, {
|
|
778
|
-
statusCode: 0,
|
|
779
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
780
|
-
});
|
|
781
|
-
lifecycle.release(id);
|
|
782
|
-
// Rethrow the original value so its identity survives.
|
|
783
|
-
throw error;
|
|
784
|
-
}
|
|
785
|
-
finally {
|
|
786
|
-
bodyCache.delete(id);
|
|
787
761
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
762
|
+
// globalThis.fetch resolved per attempt rather than captured at client
|
|
763
|
+
// creation, so test interceptors that patch it (MSW) are honored.
|
|
764
|
+
return globalThis.fetch(attemptRequest);
|
|
765
|
+
};
|
|
766
|
+
const emit = {
|
|
767
|
+
begin: (attempt) => lifecycle.begin(request, method, url, attempt),
|
|
768
|
+
finalize: (outcome) => lifecycle.finalize(request, method, url, outcome),
|
|
769
|
+
retrying: (failedAttempt, error, delayMs) => lifecycle.retrying(method, url, failedAttempt, error, delayMs),
|
|
770
|
+
};
|
|
771
|
+
try {
|
|
772
|
+
return await executeWithRetry(makeAttempt, effectiveConfig, emit, request.signal);
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
// Unwrap the terminal marker so the original error's identity survives
|
|
776
|
+
// to the lifecycle middleware's onError (and to the caller).
|
|
777
|
+
throw error instanceof TerminalRetryError ? error.reason : error;
|
|
778
|
+
}
|
|
795
779
|
};
|
|
796
780
|
}
|
|
797
|
-
function calculateBackoffDelay(config, attempt) {
|
|
798
|
-
const base = config.baseDelayMs;
|
|
799
|
-
let delay;
|
|
800
|
-
switch (config.backoff) {
|
|
801
|
-
case "exponential":
|
|
802
|
-
delay = base * Math.pow(2, attempt);
|
|
803
|
-
break;
|
|
804
|
-
case "linear":
|
|
805
|
-
delay = base * (attempt + 1);
|
|
806
|
-
break;
|
|
807
|
-
case "constant":
|
|
808
|
-
default:
|
|
809
|
-
delay = base;
|
|
810
|
-
}
|
|
811
|
-
// Add jitter (0-100ms)
|
|
812
|
-
const jitter = Math.random() * MAX_JITTER_MS;
|
|
813
|
-
return delay + jitter;
|
|
814
|
-
}
|
|
815
|
-
function sleep(ms) {
|
|
816
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
817
|
-
}
|
|
818
781
|
// =============================================================================
|
|
819
782
|
// Pagination Helper
|
|
820
783
|
// =============================================================================
|
|
821
784
|
/**
|
|
822
785
|
* Fetches all pages of a paginated resource using Link header pagination.
|
|
823
|
-
* Automatically follows rel="next" links until no more pages exist
|
|
786
|
+
* Automatically follows rel="next" links until no more pages exist, or until
|
|
787
|
+
* `maxPages` pages have been consumed.
|
|
788
|
+
*
|
|
789
|
+
* The cap is not optional in spirit: a Link header naming the page it was
|
|
790
|
+
* served from makes "until no more pages exist" never true, and the header is
|
|
791
|
+
* attacker-influenced (that is why `isSameOrigin` is checked below). Every
|
|
792
|
+
* other pagination loop in this SDK, and in the other five, is bounded the
|
|
793
|
+
* same way. Reaching the cap stops quietly — there is no meta channel on a
|
|
794
|
+
* bare `T[]` to report it, and throwing would break callers who legitimately
|
|
795
|
+
* have that many pages.
|
|
796
|
+
*
|
|
797
|
+
* @param maxPages - Safety cap on pages CONSUMED. Must be a positive integer;
|
|
798
|
+
* `0`, negatives, `NaN`, `Infinity` and non-integers all throw
|
|
799
|
+
* `BasecampError("usage")` before any request is made. Defaults to
|
|
800
|
+
* `DEFAULT_MAX_PAGES`.
|
|
801
|
+
* @throws {BasecampError} with `code: "usage"` if `maxPages` is not a positive
|
|
802
|
+
* integer.
|
|
824
803
|
*
|
|
825
804
|
* @example
|
|
826
805
|
* ```ts
|
|
@@ -832,12 +811,22 @@ function sleep(ms) {
|
|
|
832
811
|
* );
|
|
833
812
|
* ```
|
|
834
813
|
*/
|
|
835
|
-
export async function fetchAllPages(initialResponse, parse, authHeader) {
|
|
814
|
+
export async function fetchAllPages(initialResponse, parse, authHeader, maxPages = DEFAULT_MAX_PAGES) {
|
|
815
|
+
assertValidMaxPages(maxPages);
|
|
836
816
|
const results = [];
|
|
837
817
|
let response = initialResponse;
|
|
838
|
-
|
|
818
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
839
819
|
const items = await parse(response.clone());
|
|
840
820
|
results.push(...items);
|
|
821
|
+
// Before reading the Link header, not after fetching it. The loop consumes
|
|
822
|
+
// the current page at the head of the iteration and fetches the next at the
|
|
823
|
+
// tail, so testing the cap only in the `for` condition would let the final
|
|
824
|
+
// allowed iteration issue a request whose response is never parsed and
|
|
825
|
+
// never returned — and that request goes to a URL taken from an
|
|
826
|
+
// attacker-influenceable header. `maxPages` counts pages CONSUMED, matching
|
|
827
|
+
// BaseService.followPagination.
|
|
828
|
+
if (page === maxPages)
|
|
829
|
+
break;
|
|
841
830
|
const rawNextUrl = parseNextLink(response.headers.get("Link"));
|
|
842
831
|
if (!rawNextUrl)
|
|
843
832
|
break;
|
|
@@ -859,6 +848,23 @@ export async function fetchAllPages(initialResponse, parse, authHeader) {
|
|
|
859
848
|
* Async generator that yields pages of results one at a time.
|
|
860
849
|
* Useful for processing large datasets without loading everything into memory.
|
|
861
850
|
*
|
|
851
|
+
* Bounded by `maxPages` for the same reason as {@link fetchAllPages}: a Link
|
|
852
|
+
* header that points at its own page would otherwise yield forever.
|
|
853
|
+
*
|
|
854
|
+
* Validation is EAGER, which is why this is a plain function returning a
|
|
855
|
+
* generator rather than an `async function*`. The body of an `async function*`
|
|
856
|
+
* does not run until something iterates it, so a check written inside one would
|
|
857
|
+
* surface a bad `maxPages` at some later `for await` — possibly in a different
|
|
858
|
+
* function, on a generator that was passed along. A usage error is a programmer
|
|
859
|
+
* error and belongs at the call site that made it.
|
|
860
|
+
*
|
|
861
|
+
* @param maxPages - Safety cap on pages CONSUMED. Must be a positive integer;
|
|
862
|
+
* `0`, negatives, `NaN`, `Infinity` and non-integers all throw
|
|
863
|
+
* `BasecampError("usage")` from this call, before the generator is created
|
|
864
|
+
* and before any request is made. Defaults to `DEFAULT_MAX_PAGES`.
|
|
865
|
+
* @throws {BasecampError} with `code: "usage"` if `maxPages` is not a positive
|
|
866
|
+
* integer.
|
|
867
|
+
*
|
|
862
868
|
* @example
|
|
863
869
|
* ```ts
|
|
864
870
|
* for await (const page of paginateAll(response.response, (r) => r.json() as Promise<any[]>)) {
|
|
@@ -866,11 +872,25 @@ export async function fetchAllPages(initialResponse, parse, authHeader) {
|
|
|
866
872
|
* }
|
|
867
873
|
* ```
|
|
868
874
|
*/
|
|
869
|
-
export
|
|
875
|
+
export function paginateAll(initialResponse, parse, authHeader, maxPages = DEFAULT_MAX_PAGES) {
|
|
876
|
+
assertValidMaxPages(maxPages);
|
|
877
|
+
return paginatePages(initialResponse, parse, authHeader, maxPages);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* The generator body behind {@link paginateAll}. Split out only so the cap can
|
|
881
|
+
* be validated before the generator object exists; `maxPages` is already known
|
|
882
|
+
* good here.
|
|
883
|
+
*/
|
|
884
|
+
async function* paginatePages(initialResponse, parse, authHeader, maxPages) {
|
|
870
885
|
let response = initialResponse;
|
|
871
|
-
|
|
886
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
872
887
|
const items = await parse(response.clone());
|
|
873
888
|
yield items;
|
|
889
|
+
// See {@link fetchAllPages}: breaking here rather than relying on the loop
|
|
890
|
+
// condition is what keeps the last allowed iteration from issuing a fetch
|
|
891
|
+
// whose response is never yielded.
|
|
892
|
+
if (page === maxPages)
|
|
893
|
+
break;
|
|
874
894
|
const rawNextUrl = parseNextLink(response.headers.get("Link"));
|
|
875
895
|
if (!rawNextUrl)
|
|
876
896
|
break;
|